[{"data":1,"prerenderedAt":1748},["ShallowReactive",2],{"page:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Ffare_enough":3,"translations:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Ffare_enough":1743},{"id":4,"title":5,"author":6,"body":7,"date":1730,"description":1731,"draft":1732,"extension":1733,"image":1734,"listed":297,"meta":1735,"navigation":297,"path":1736,"seo":1737,"stem":1738,"tags":1739,"theme":1741,"toc":297,"__hash__":1742},"content_ru\u002Fru\u002Fposts\u002Fcode_n_shit\u002Ffare_enough.md","Я сделал таксометр для айфона. Просто так.","slnt_opp",{"type":8,"value":9,"toc":1721},"minimark",[10,14,21,26,29,32,36,39,61,76,80,83,86,92,95,99,108,134,455,465,711,714,717,735,829,868,871,875,882,1003,1006,1053,1060,1063,1203,1206,1210,1213,1227,1233,1519,1533,1540,1546,1550,1565,1568,1690,1693,1697,1700,1715,1718],[11,12,13],"p",{},"Сидел когда-нибудь в такси, смотрел, как тикают красные цифры, и думал, сколько бы та же поездка стоила в Мадриде? Или в нью-йоркском жёлтом кэбе? Я да, поэтому сделал таксометр.",[11,15,16,20],{},[17,18,19],"strong",{},"Fare Enough"," превращает айфон в таксометр, просто так, для души. Выбираешь опубликованный тариф настоящего города, едешь, и приложение показывает, сколько бы стоила поездка там: расстояние по GPS, ожидание, ночной тариф, доплаты вроде багажа. В конце получаешь чек.",[22,23,25],"h2",{"id":24},"а-зачем-вообще-таксометр","А зачем вообще таксометр",[11,27,28],{},"Где-то лежит VHS-кассета (помните такие, дети? 📼), на которой трёх- или четырёхлетнего меня в детском саду спрашивают, кем я хочу стать, когда вырасту. Ответ без секунды сомнений: таксистом. У жизни были другие планы, и пришлось довольствоваться айти, но водить я до сих пор обожаю.",[11,30,31],{},"Вторая половина истории: LARP — моё второе имя. Принтер чеков у меня появился раньше, чем машина. До сих пор я в основном отыгрывал голландца, а теперь в этот отыгрыш можно вшить и мечту из детского сада. Четырёхлетний я был бы очень горд. Остальным не обязательно.",[22,33,35],{"id":34},"что-внутри","Что внутри",[11,37,38],{},"Это просто приложение, но вот что оно умеет:",[40,41,42,46,49,52,55,58],"ul",{},[43,44,45],"li",{},"Тарифы 19 городов в 5 странах: Мадрид, Барселона, Берлин, Мюнхен, Варшава, Краков, Нью-Йорк, Чикаго, Лондон, Эдинбург и ещё несколько, каждый списан с опубликованных городских расценок. Ночной и праздничный тарифы включаются сами.",[43,47,48],{},"Поверни телефон боком, и это старый добрый таксометр со светящимися цифрами и увесистыми кнопками.",[43,50,51],{},"Чек в конце поездки: PDF, картинка или бумажка из крошечного Bluetooth-принтера.",[43,53,54],{},"Стоимость поездки на экране блокировки, пока едешь. CarPlay готов и ждёт разрешения от Apple.",[43,56,57],{},"Скидки для друзей: не будешь же ты выставлять другу полную цену, даже понарошку.",[43,59,60],{},"Бесплатно, без рекламы, без аккаунта и без аналитики. С телефона ничего не уходит, кроме запросов к Apple Maps.",[11,62,63,64,67,68,75],{},"Пока я это пишу, приложение ждёт проверки в App Store (под именем ",[17,65,66],{},"Fare Enough Meter",": «Fare Enough» уже было занято). В день выхода ссылка появится на ",[69,70,74],"a",{"href":71,"rel":72},"https:\u002F\u002Ffare.slnt-opp.xyz",[73],"nofollow","fare.slnt-opp.xyz",".",[77,78],"gallery",{":images":79},"[{\"src\":\"\u002Fimages\u002Ffare_enough\u002Fapp-meter.jpg\",\"alt\":\"Счётчик во время поездки по Нью-Йорку\"},{\"src\":\"\u002Fimages\u002Ffare_enough\u002Fapp-receipt.jpg\",\"alt\":\"Чек за поездку, с маршрутом\"}]",[11,81,82],{},"А вот лицевая панель счётчика, можно понажимать: веб-копия, нарисованная по той же геометрии, что и в приложении. Жми «Старт».",[84,85],"fare-enough-meter",{},[87,88,89],"note",{},[11,90,91],{},"Fare Enough — не сертифицированный и не поверенный таксометр, и он не для профессиональных водителей. Внизу каждого чека так и написано: \"Not a fiscal receipt. Made with Fare Enough.\"",[11,93,94],{},"А теперь самое интересное: три вещи, над которыми пришлось больше всего поломать голову.",[22,96,98],{"id":97},"skip-и-счётчик-которому-предстоит-пережить-android","Skip и счётчик, которому предстоит пережить Android",[11,100,101,102,107],{},"Сейчас приложение на SwiftUI. Android будет позже, через ",[69,103,106],{"href":104,"rel":105},"https:\u002F\u002Fskip.dev",[73],"Skip",": в режиме Fuse он компилирует Swift нативно под Android и рисует SwiftUI через Jetpack Compose. План такой: порт добавит горстку файлов и ничего не сдвинет. Поэтому приложение с первого коммита пишется с прицелом на это: каждый вызов Apple SDK живёт в отдельном платформенном файле за протоколом, а ядро (тарифы, арифметика счётчика, чеки) не импортирует ничего, кроме Foundation.",[11,109,110,113,114,118,119,122,123,126,127,126,130,133],{},[17,111,112],{},"Насколько сложно сделать «игровой» интерфейс по таким правилам? Рисование оказалось самой простой частью."," В Skip ",[115,116,117],"code",{},"Shape"," и ",[115,120,121],{},"Path"," и есть тот API для рисования, который переезжает на Android. Никакого ",[115,124,125],{},"Canvas",", никакого ",[115,128,129],{},"TimelineView",[115,131,132],{},"ImageRenderer"," в общем коде, и ничто, что несёт смысл, не может зависеть от режимов наложения или 3D-поворотов. Поэтому каждая цифра — это геометрия: шестиугольные сегменты, посчитанные от высоты цифры, а курсивный наклон встроен прямо в отображение точек, а не накладывается трансформацией потом.",[135,136,141],"pre",{"className":137,"code":138,"language":139,"meta":140,"style":140},"language-swift shiki shiki-themes monokai","\u002F\u002F A point in one cell, slant included: x' = x + (h − y)·tan 8°\nfunc point(cell: Int, x: Double, y: Double) -> (x: Double, y: Double) {\n    let left = originX + Double(cell) * style.pitch * height\n    return (left + x + (height - y) * style.slant, originY + y)\n}\n\n\u002F\u002F A horizontal bar: a mitred hexagon between two x's.\nfunc horizontal(_ y: Double) -> [(x: Double, y: Double)] {\n    let x0 = left + g, x1 = right - g\n    return [pt(x0, y), pt(x0 + half, y - half),\n            pt(x1 - half, y - half), pt(x1, y),\n            pt(x1 - half, y + half), pt(x0 + half, y + half)]\n}\n","swift","",[115,142,143,152,216,251,286,292,299,305,342,369,399,422,450],{"__ignoreMap":140},[144,145,148],"span",{"class":146,"line":147},"line",1,[144,149,151],{"class":150},"snpHw","\u002F\u002F A point in one cell, slant included: x' = x + (h − y)·tan 8°\n",[144,153,155,159,163,167,170,173,176,179,182,184,187,189,192,194,196,199,203,206,208,211,213],{"class":146,"line":154},2,[144,156,158],{"class":157},"sOx1s","func",[144,160,162],{"class":161},"sHkqI"," point",[144,164,166],{"class":165},"sCdxs","(",[144,168,169],{"class":161},"cell",[144,171,172],{"class":165},": ",[144,174,175],{"class":157},"Int",[144,177,178],{"class":165},", ",[144,180,181],{"class":161},"x",[144,183,172],{"class":165},[144,185,186],{"class":157},"Double",[144,188,178],{"class":165},[144,190,191],{"class":161},"y",[144,193,172],{"class":165},[144,195,186],{"class":157},[144,197,198],{"class":165},") ",[144,200,202],{"class":201},"s8I7P","->",[144,204,205],{"class":165}," (x: ",[144,207,186],{"class":157},[144,209,210],{"class":165},", y: ",[144,212,186],{"class":157},[144,214,215],{"class":165},") {\n",[144,217,219,222,225,228,231,234,237,240,243,246,248],{"class":146,"line":218},3,[144,220,221],{"class":201},"    let",[144,223,224],{"class":165}," left ",[144,226,227],{"class":201},"=",[144,229,230],{"class":165}," originX ",[144,232,233],{"class":201},"+",[144,235,236],{"class":157}," Double",[144,238,239],{"class":165},"(cell) ",[144,241,242],{"class":201},"*",[144,244,245],{"class":165}," style.pitch ",[144,247,242],{"class":201},[144,249,250],{"class":165}," height\n",[144,252,254,257,260,262,265,267,270,273,276,278,281,283],{"class":146,"line":253},4,[144,255,256],{"class":201},"    return",[144,258,259],{"class":165}," (left ",[144,261,233],{"class":201},[144,263,264],{"class":165}," x ",[144,266,233],{"class":201},[144,268,269],{"class":165}," (height ",[144,271,272],{"class":201},"-",[144,274,275],{"class":165}," y) ",[144,277,242],{"class":201},[144,279,280],{"class":165}," style.slant, originY ",[144,282,233],{"class":201},[144,284,285],{"class":165}," y)\n",[144,287,289],{"class":146,"line":288},5,[144,290,291],{"class":165},"}\n",[144,293,295],{"class":146,"line":294},6,[144,296,298],{"emptyLinePlaceholder":297},true,"\n",[144,300,302],{"class":146,"line":301},7,[144,303,304],{"class":150},"\u002F\u002F A horizontal bar: a mitred hexagon between two x's.\n",[144,306,308,310,313,315,318,322,324,326,328,330,333,335,337,339],{"class":146,"line":307},8,[144,309,158],{"class":157},[144,311,312],{"class":161}," horizontal",[144,314,166],{"class":165},[144,316,317],{"class":161},"_",[144,319,321],{"class":320},"sW0Xf"," y",[144,323,172],{"class":165},[144,325,186],{"class":157},[144,327,198],{"class":165},[144,329,202],{"class":201},[144,331,332],{"class":165}," [(x: ",[144,334,186],{"class":157},[144,336,210],{"class":165},[144,338,186],{"class":157},[144,340,341],{"class":165},")] {\n",[144,343,345,347,350,352,354,356,359,361,364,366],{"class":146,"line":344},9,[144,346,221],{"class":201},[144,348,349],{"class":165}," x0 ",[144,351,227],{"class":201},[144,353,224],{"class":165},[144,355,233],{"class":201},[144,357,358],{"class":165}," g, x1 ",[144,360,227],{"class":201},[144,362,363],{"class":165}," right ",[144,365,272],{"class":201},[144,367,368],{"class":165}," g\n",[144,370,372,374,377,381,384,386,389,391,394,396],{"class":146,"line":371},10,[144,373,256],{"class":201},[144,375,376],{"class":165}," [",[144,378,380],{"class":379},"sYf5A","pt",[144,382,383],{"class":165},"(x0, y), ",[144,385,380],{"class":379},[144,387,388],{"class":165},"(x0 ",[144,390,233],{"class":201},[144,392,393],{"class":165}," half, y ",[144,395,272],{"class":201},[144,397,398],{"class":165}," half),\n",[144,400,402,405,408,410,412,414,417,419],{"class":146,"line":401},11,[144,403,404],{"class":379},"            pt",[144,406,407],{"class":165},"(x1 ",[144,409,272],{"class":201},[144,411,393],{"class":165},[144,413,272],{"class":201},[144,415,416],{"class":165}," half), ",[144,418,380],{"class":379},[144,420,421],{"class":165},"(x1, y),\n",[144,423,425,427,429,431,433,435,437,439,441,443,445,447],{"class":146,"line":424},12,[144,426,404],{"class":379},[144,428,407],{"class":165},[144,430,272],{"class":201},[144,432,393],{"class":165},[144,434,233],{"class":201},[144,436,416],{"class":165},[144,438,380],{"class":379},[144,440,388],{"class":165},[144,442,233],{"class":201},[144,444,393],{"class":165},[144,446,233],{"class":201},[144,448,449],{"class":165}," half)]\n",[144,451,453],{"class":146,"line":452},13,[144,454,291],{"class":165},[11,456,457,458,461,462,464],{},"Свечение — это четыре слоя одного и того же пути, как у настоящего светодиода: «призрак» (все сегменты, едва заметные, тот самый вид «88.88»), ореол (горящий путь, размытый), тело и тонкая «горячая сердцевина» сверху. Каждый слой — ",[17,459,460],{},"один"," ",[115,463,121],{}," на весь дисплей, то есть одна заливка и одно размытие, сколько бы цифр там ни было. Размытие только украшает: если однажды Compose нарисует его проще, счётчик потеряет блеск, но не информацию.",[135,466,468],{"className":137,"code":467,"language":139,"meta":140,"style":140},"ZStack {\n    SegmentGlyphs(cells: ghost, frame: ghost, style: style)  \u002F\u002F ghost\n        .fill(Color(hex: skin.ghost, opacity: skin.ghostOpacity))\n    SegmentGlyphs(cells: lit, frame: ghost, style: style)    \u002F\u002F bloom\n        .fill(Color(hex: glow))\n        .blur(radius: barThickness * skin.bloomRadius)\n        .opacity(skin.bloomOpacity)\n    SegmentGlyphs(cells: lit, frame: ghost, style: style)    \u002F\u002F body\n        .fill(Color(hex: skin.lit))\n    SegmentGlyphs(cells: lit, frame: ghost, style: style,\n                  insetRatio: 0.28)                           \u002F\u002F hot core\n        .fill(Color(hex: hot))\n        .opacity(0.35)\n}\n",[115,469,470,478,505,532,555,572,592,601,622,639,658,675,692,706],{"__ignoreMap":140},[144,471,472,475],{"class":146,"line":147},[144,473,474],{"class":379},"ZStack",[144,476,477],{"class":165}," {\n",[144,479,480,483,485,488,491,494,496,499,502],{"class":146,"line":154},[144,481,482],{"class":379},"    SegmentGlyphs",[144,484,166],{"class":165},[144,486,487],{"class":379},"cells",[144,489,490],{"class":165},": ghost, ",[144,492,493],{"class":379},"frame",[144,495,490],{"class":165},[144,497,498],{"class":379},"style",[144,500,501],{"class":165},": style)  ",[144,503,504],{"class":150},"\u002F\u002F ghost\n",[144,506,507,510,513,515,518,520,523,526,529],{"class":146,"line":218},[144,508,509],{"class":165},"        .",[144,511,512],{"class":379},"fill",[144,514,166],{"class":165},[144,516,517],{"class":379},"Color",[144,519,166],{"class":165},[144,521,522],{"class":379},"hex",[144,524,525],{"class":165},": skin.ghost, ",[144,527,528],{"class":379},"opacity",[144,530,531],{"class":165},": skin.ghostOpacity))\n",[144,533,534,536,538,540,543,545,547,549,552],{"class":146,"line":253},[144,535,482],{"class":379},[144,537,166],{"class":165},[144,539,487],{"class":379},[144,541,542],{"class":165},": lit, ",[144,544,493],{"class":379},[144,546,490],{"class":165},[144,548,498],{"class":379},[144,550,551],{"class":165},": style)    ",[144,553,554],{"class":150},"\u002F\u002F bloom\n",[144,556,557,559,561,563,565,567,569],{"class":146,"line":288},[144,558,509],{"class":165},[144,560,512],{"class":379},[144,562,166],{"class":165},[144,564,517],{"class":379},[144,566,166],{"class":165},[144,568,522],{"class":379},[144,570,571],{"class":165},": glow))\n",[144,573,574,576,579,581,584,587,589],{"class":146,"line":294},[144,575,509],{"class":165},[144,577,578],{"class":379},"blur",[144,580,166],{"class":165},[144,582,583],{"class":379},"radius",[144,585,586],{"class":165},": barThickness ",[144,588,242],{"class":201},[144,590,591],{"class":165}," skin.bloomRadius)\n",[144,593,594,596,598],{"class":146,"line":301},[144,595,509],{"class":165},[144,597,528],{"class":379},[144,599,600],{"class":165},"(skin.bloomOpacity)\n",[144,602,603,605,607,609,611,613,615,617,619],{"class":146,"line":307},[144,604,482],{"class":379},[144,606,166],{"class":165},[144,608,487],{"class":379},[144,610,542],{"class":165},[144,612,493],{"class":379},[144,614,490],{"class":165},[144,616,498],{"class":379},[144,618,551],{"class":165},[144,620,621],{"class":150},"\u002F\u002F body\n",[144,623,624,626,628,630,632,634,636],{"class":146,"line":344},[144,625,509],{"class":165},[144,627,512],{"class":379},[144,629,166],{"class":165},[144,631,517],{"class":379},[144,633,166],{"class":165},[144,635,522],{"class":379},[144,637,638],{"class":165},": skin.lit))\n",[144,640,641,643,645,647,649,651,653,655],{"class":146,"line":371},[144,642,482],{"class":379},[144,644,166],{"class":165},[144,646,487],{"class":379},[144,648,542],{"class":165},[144,650,493],{"class":379},[144,652,490],{"class":165},[144,654,498],{"class":379},[144,656,657],{"class":165},": style,\n",[144,659,660,663,665,669,672],{"class":146,"line":401},[144,661,662],{"class":379},"                  insetRatio",[144,664,172],{"class":165},[144,666,668],{"class":667},"s7s5_","0.28",[144,670,671],{"class":165},")                           ",[144,673,674],{"class":150},"\u002F\u002F hot core\n",[144,676,677,679,681,683,685,687,689],{"class":146,"line":424},[144,678,509],{"class":165},[144,680,512],{"class":379},[144,682,166],{"class":165},[144,684,517],{"class":379},[144,686,166],{"class":165},[144,688,522],{"class":379},[144,690,691],{"class":165},": hot))\n",[144,693,694,696,698,700,703],{"class":146,"line":452},[144,695,509],{"class":165},[144,697,528],{"class":379},[144,699,166],{"class":165},[144,701,702],{"class":667},"0.35",[144,704,705],{"class":165},")\n",[144,707,709],{"class":146,"line":708},14,[144,710,291],{"class":165},[11,712,713],{},"А дальше скин — это просто цвета на той же геометрии: красный светодиод, янтарный, зелёный, голубой VFD и серый LCD (плюс винтажный с крутящимися барабанами).",[77,715],{":images":716},"[{\"src\":\"\u002Fimages\u002Ffare_enough\u002Fmeter-madrid.jpg\",\"alt\":\"Красный светодиод, поездка по мадридскому тарифу 1\"},{\"src\":\"\u002Fimages\u002Ffare_enough\u002Fskin-vfd.jpg\",\"alt\":\"VFD\"},{\"src\":\"\u002Fimages\u002Ffare_enough\u002Fskin-lcd.jpg\",\"alt\":\"LCD\"},{\"src\":\"\u002Fimages\u002Ffare_enough\u002Fskin-amber.jpg\",\"alt\":\"Янтарный\"},{\"src\":\"\u002Fimages\u002Ffare_enough\u002Fskin-green.jpg\",\"alt\":\"Зелёный\"}]",[11,718,719,720,723,724,726,727,730,731,734],{},"Мигающее двоеточие и тест ламп при включении крутятся в маленьком цикле на ",[115,721,722],{},"Task"," с частотой 2 Гц и только пока счётчик на экране, потому что ",[115,725,129],{}," в Skip нет. С кнопками тоже пришлось схитрить: своего ",[115,728,729],{},"ButtonStyle"," в Skip нет (есть только ",[115,732,733],{},"PrimitiveButtonStyle","), поэтому вид нажатой кнопки живёт в одной прослойке совместимости с отдельной веткой под Android:",[135,736,738],{"className":137,"code":737,"language":139,"meta":140,"style":140},"func meterKeyStyle() -> some View {\n    #if os(Android)\n    self.buttonStyle(.plain)\n    #else\n    \u002F\u002F Pressed: brightness −0.14 and 1 pt down.\n    self.buttonStyle(MeterKeyPressStyle())\n    #endif\n}\n",[115,739,740,758,776,790,797,802,818,825],{"__ignoreMap":140},[144,741,742,744,747,750,752,755],{"class":146,"line":147},[144,743,158],{"class":157},[144,745,746],{"class":161}," meterKeyStyle",[144,748,749],{"class":165},"() ",[144,751,202],{"class":201},[144,753,754],{"class":201}," some",[144,756,757],{"class":165}," View {\n",[144,759,760,763,766,769,771,774],{"class":146,"line":154},[144,761,762],{"class":165},"    #",[144,764,765],{"class":201},"if",[144,767,768],{"class":201}," os",[144,770,166],{"class":165},[144,772,773],{"class":379},"Android",[144,775,705],{"class":165},[144,777,778,782,784,787],{"class":146,"line":218},[144,779,781],{"class":780},"szGx7","    self",[144,783,75],{"class":165},[144,785,786],{"class":379},"buttonStyle",[144,788,789],{"class":165},"(.plain)\n",[144,791,792,794],{"class":146,"line":253},[144,793,762],{"class":165},[144,795,796],{"class":201},"else\n",[144,798,799],{"class":146,"line":288},[144,800,801],{"class":150},"    \u002F\u002F Pressed: brightness −0.14 and 1 pt down.\n",[144,803,804,806,808,810,812,815],{"class":146,"line":294},[144,805,781],{"class":780},[144,807,75],{"class":165},[144,809,786],{"class":379},[144,811,166],{"class":165},[144,813,814],{"class":379},"MeterKeyPressStyle",[144,816,817],{"class":165},"())\n",[144,819,820,822],{"class":146,"line":301},[144,821,762],{"class":165},[144,823,824],{"class":201},"endif\n",[144,826,827],{"class":146,"line":307},[144,828,291],{"class":165},[11,830,831,834,835,838,839,843,844,847,848,851,852,855,856,859,860,863,864,867],{},[17,832,833],{},"Сложное — это всё, что прекрасно компилируется под iOS и ломается только на Android",", через несколько недель. ",[115,836,837],{},"Text(\"Fare\")"," ещё раз ищет строку в переводах на языке ",[840,841,842],"em",{},"устройства",", так что в одной строке интерфейса могут смешаться два языка: писать нужно ",[115,845,846],{},"Text(verbatim: loc(\"Fare\"))",". ",[115,849,850],{},"didSet"," у свойства ",[115,853,854],{},"@Observable"," в приложении никогда не срабатывает (а в юнит-тестах срабатывает, так оно и прячется). ",[115,857,858],{},"ForEach"," по чему угодно, кроме ",[115,861,862],{},"Array",", роняет приложение. Иконка под Android есть только у 73 SF Symbols, любое другое имя молча ничего не рисует. ",[115,865,866],{},".foregroundStyle(.tertiary)"," — это краш. Компилятор ничего из этого не ловит, поэтому каждое правило — это тест, который читает исходники на Swift как текст и валит сборку, заодно объясняя, как починить. Правило, которое живёт только в документации, протягивает недель шесть.",[11,869,870],{},"Справедливости ради: сборки под Android пока нет. Весь этот раздел — ставка на то, что когда она появится, будет скучно.",[22,872,874],{"id":873},"фоновая-геолокация-или-как-поездка-превратилась-в-ожидание","Фоновая геолокация, или как поездка превратилась в ожидание",[11,876,877,878,881],{},"Счётчик должен продолжать мерить, пока телефон заблокирован и висит в держателе, и при этом просить как можно меньше: только разрешение ",[17,879,880],{},"«При использовании»",", никогда «Всегда». Это работает, но только когда всё на своих местах:",[135,883,885],{"className":137,"code":884,"language":139,"meta":140,"style":140},"\u002F\u002F A car on roads (.otherNavigation is boats and trains).\nmanager.activityType = .automotiveNavigation\nmanager.desiredAccuracy = kCLLocationAccuracyBestForNavigation\nmanager.distanceFilter = kCLDistanceFilterNone\n\u002F\u002F true = iOS stops a meter waiting in traffic, for good.\nmanager.pausesLocationUpdatesAutomatically = false\n\u002F\u002F Read from the *built* Info.plist: without the mode, this line kills the app.\nif Self.hasLocationBackgroundMode {\n    manager.allowsBackgroundLocationUpdates = true\n}\nmanager.showsBackgroundLocationIndicator = true\n\u002F\u002F iOS 17+: held while the meter runs.\nbackgroundSession = CLBackgroundActivitySession()\nmanager.startUpdatingLocation()\n",[115,886,887,892,902,912,922,927,937,942,952,962,966,975,980,993],{"__ignoreMap":140},[144,888,889],{"class":146,"line":147},[144,890,891],{"class":150},"\u002F\u002F A car on roads (.otherNavigation is boats and trains).\n",[144,893,894,897,899],{"class":146,"line":154},[144,895,896],{"class":165},"manager.activityType ",[144,898,227],{"class":201},[144,900,901],{"class":165}," .automotiveNavigation\n",[144,903,904,907,909],{"class":146,"line":218},[144,905,906],{"class":165},"manager.desiredAccuracy ",[144,908,227],{"class":201},[144,910,911],{"class":165}," kCLLocationAccuracyBestForNavigation\n",[144,913,914,917,919],{"class":146,"line":253},[144,915,916],{"class":165},"manager.distanceFilter ",[144,918,227],{"class":201},[144,920,921],{"class":165}," kCLDistanceFilterNone\n",[144,923,924],{"class":146,"line":288},[144,925,926],{"class":150},"\u002F\u002F true = iOS stops a meter waiting in traffic, for good.\n",[144,928,929,932,934],{"class":146,"line":294},[144,930,931],{"class":165},"manager.pausesLocationUpdatesAutomatically ",[144,933,227],{"class":201},[144,935,936],{"class":667}," false\n",[144,938,939],{"class":146,"line":301},[144,940,941],{"class":150},"\u002F\u002F Read from the *built* Info.plist: without the mode, this line kills the app.\n",[144,943,944,946,949],{"class":146,"line":307},[144,945,765],{"class":201},[144,947,948],{"class":780}," Self",[144,950,951],{"class":165},".hasLocationBackgroundMode {\n",[144,953,954,957,959],{"class":146,"line":344},[144,955,956],{"class":165},"    manager.allowsBackgroundLocationUpdates ",[144,958,227],{"class":201},[144,960,961],{"class":667}," true\n",[144,963,964],{"class":146,"line":371},[144,965,291],{"class":165},[144,967,968,971,973],{"class":146,"line":401},[144,969,970],{"class":165},"manager.showsBackgroundLocationIndicator ",[144,972,227],{"class":201},[144,974,961],{"class":667},[144,976,977],{"class":146,"line":424},[144,978,979],{"class":150},"\u002F\u002F iOS 17+: held while the meter runs.\n",[144,981,982,985,987,990],{"class":146,"line":452},[144,983,984],{"class":165},"backgroundSession ",[144,986,227],{"class":201},[144,988,989],{"class":379}," CLBackgroundActivitySession",[144,991,992],{"class":165},"()\n",[144,994,995,998,1001],{"class":146,"line":708},[144,996,997],{"class":165},"manager.",[144,999,1000],{"class":379},"startUpdatingLocation",[144,1002,992],{"class":165},[11,1004,1005],{},"Грабли, в том порядке, в каком я на них наступал:",[40,1007,1008,1018,1030,1043],{},[43,1009,1010,1013,1014,1017],{},[17,1011,1012],{},"«Режим автомобиля»"," — это ",[115,1015,1016],{},"activityType = .automotiveNavigation",". Так Core Location понимает, что это машина на дорогах, и это меняет то, как iOS фильтрует точки и когда ставит их на паузу.",[43,1019,1020,1029],{},[17,1021,1022,1025,1026,75],{},[115,1023,1024],{},"pausesLocationUpdatesAutomatically"," должно быть ",[115,1027,1028],{},"false"," Иначе iOS решает, что машина, стоящая на светофоре, приехала, и ставит обновления на паузу, а приложение в фоне не получит их обратно, пока его не откроют. А ожидание — это ровно то время, когда таксометр зарабатывает.",[43,1031,1032,461,1035,1038,1039,1042],{},[17,1033,1034],{},"Фоновый режим должен быть в самом файле Info.plist.",[115,1036,1037],{},"INFOPLIST_KEY_UIBackgroundModes"," в настройках сборки молча не доходит до собранного приложения, а ",[115,1040,1041],{},"allowsBackgroundLocationUpdates = true"," без этого режима не бросает ошибку: оно убивает приложение. Поэтому приложение сначала проверяет свой собственный собранный бандл, а скрипт проверяет собранное приложение перед каждым релизом.",[43,1044,1045,1048,1049,1052],{},[17,1046,1047],{},"Первая точка — часто вчерашние новости."," Сразу после ",[115,1050,1051],{},"startUpdatingLocation()"," iOS отдаёт закешированную позицию, а это может быть место прошлой высадки за сотни метров. Точки старше 30 секунд выбрасываются.",[11,1054,1055,1056,1059],{},"Потом был настоящий полевой тест. Поездка по Магдебургу вернулась как ",[17,1057,1058],{},"13 мин 39 с ожидания и ни строчки про расстояние",", хотя 1,15 км было намеряно. Телефон отдавал точки с опозданием, на несколько секунд или пачками, пока был заблокирован, а часы счётчика тарифицировали каждую секунду по мере её прохождения, по скорости последней известной точки: ноль. Каждая секунда выглядела как стоянка.",[11,1061,1062],{},"Решение: пусть точки тарифицируют себя сами. Пока они продолжают приходить, часы не вмешиваются:",[135,1064,1066],{"className":137,"code":1065,"language":139,"meta":140,"style":140},"public mutating func tick(at date: Date) {\n    guard state.status == .hired else { return }\n    \u002F\u002F Fixes are still coming, however late: each one bills\n    \u002F\u002F its own seconds, at its own speed, when it lands.\n    if let arrived = state.lastFixArrival, state.gps.lastFix != nil,\n       date.timeIntervalSince(arrived) \u003C= state.gps.gapSeconds {\n        return\n    }\n    \u002F\u002F The GPS has been silent for 10 s: now the clock bills waiting.\n    advance(to: date)\n}\n",[115,1067,1068,1093,1119,1124,1129,1154,1171,1176,1181,1186,1199],{"__ignoreMap":140},[144,1069,1070,1073,1076,1079,1082,1084,1087,1090],{"class":146,"line":147},[144,1071,1072],{"class":201},"public",[144,1074,1075],{"class":201}," mutating",[144,1077,1078],{"class":157}," func",[144,1080,1081],{"class":161}," tick",[144,1083,166],{"class":165},[144,1085,1086],{"class":161},"at",[144,1088,1089],{"class":320}," date",[144,1091,1092],{"class":165},": Date) {\n",[144,1094,1095,1098,1101,1104,1107,1110,1113,1116],{"class":146,"line":154},[144,1096,1097],{"class":201},"    guard",[144,1099,1100],{"class":165}," state.status ",[144,1102,1103],{"class":201},"==",[144,1105,1106],{"class":165}," .hired ",[144,1108,1109],{"class":201},"else",[144,1111,1112],{"class":165}," { ",[144,1114,1115],{"class":201},"return",[144,1117,1118],{"class":165}," }\n",[144,1120,1121],{"class":146,"line":218},[144,1122,1123],{"class":150},"    \u002F\u002F Fixes are still coming, however late: each one bills\n",[144,1125,1126],{"class":146,"line":253},[144,1127,1128],{"class":150},"    \u002F\u002F its own seconds, at its own speed, when it lands.\n",[144,1130,1131,1134,1137,1140,1142,1145,1148,1151],{"class":146,"line":288},[144,1132,1133],{"class":201},"    if",[144,1135,1136],{"class":201}," let",[144,1138,1139],{"class":165}," arrived ",[144,1141,227],{"class":201},[144,1143,1144],{"class":165}," state.lastFixArrival, state.gps.lastFix ",[144,1146,1147],{"class":201},"!=",[144,1149,1150],{"class":667}," nil",[144,1152,1153],{"class":165},",\n",[144,1155,1156,1159,1162,1165,1168],{"class":146,"line":294},[144,1157,1158],{"class":165},"       date.",[144,1160,1161],{"class":379},"timeIntervalSince",[144,1163,1164],{"class":165},"(arrived) ",[144,1166,1167],{"class":201},"\u003C=",[144,1169,1170],{"class":165}," state.gps.gapSeconds {\n",[144,1172,1173],{"class":146,"line":301},[144,1174,1175],{"class":201},"        return\n",[144,1177,1178],{"class":146,"line":307},[144,1179,1180],{"class":165},"    }\n",[144,1182,1183],{"class":146,"line":344},[144,1184,1185],{"class":150},"    \u002F\u002F The GPS has been silent for 10 s: now the clock bills waiting.\n",[144,1187,1188,1191,1193,1196],{"class":146,"line":371},[144,1189,1190],{"class":379},"    advance",[144,1192,166],{"class":165},[144,1194,1195],{"class":379},"to",[144,1197,1198],{"class":165},": date)\n",[144,1200,1201],{"class":146,"line":401},[144,1202,291],{"class":165},[11,1204,1205],{},"Опоздавшая точка пересчитывает поездку от состояния счётчика на момент предыдущей точки, и сумма на экране при этом никогда не уменьшается. Маршруты в симуляторе нужно прогонять в 1×: iOS ставит на точку время её прихода, так что ускоренный прогон — это более быстрая поездка, а не та же поездка, только раньше. А на странице для разработчиков теперь есть статистика того, как телефон доставлял точки (сколько пришло в фоне, с каким опозданием, самая долгая тишина), потому что лог, который можно прочитать на самом телефоне, лучше гаданий.",[22,1207,1209],{"id":1208},"принтер-чеков-по-цене-пиццы","Принтер чеков по цене пиццы",[11,1211,1212],{},"Phomemo M110 — карманный принтер этикеток: 203 dpi, печатающая головка на 384 точки (48 мм), Bluetooth LE. Последовательность байтов взята из того, что отправляют два опенсорсных драйвера (pyphomemo и phomymo, спасибо обоим), и из кучи напечатанных тестовых страниц.",[11,1214,1215,1218,1219,1222,1223,1226],{},[17,1216,1217],{},"Первые грабли — найти его."," Большинство M110 не рекламируют ничего, кроме серийного номера вроде ",[115,1220,1221],{},"Q119G18Q0830029",": Q, три цифры, буква, две цифры, буква, семь цифр. Сервисы принтера, ",[115,1224,1225],{},"FF00"," и компания, — обычные последовательные сервисы, которыми пользуются и колонки, и лампы, и трекеры. Однажды по ним в список принтеров попала Bluetooth-колонка соседа под видом M110. Поэтому приложение узнаёт принтеры только по имени.",[11,1228,1229,1232],{},[17,1230,1231],{},"Вторые грабли — тайминги."," Если отправить задание одним потоком, принтер моргнёт лампочкой и всё выбросит. Ему нужны настройки отдельными записями с паузами, один растровый заголовок с полной высотой, строки маленькими порциями и завершение после передышки:",[135,1234,1236],{"className":137,"code":1235,"language":139,"meta":140,"style":140},"static func steps(for bitmap: ThermalBitmap,\n                  settings: PrintSettings) -> [PrintStep] {\n    \u002F\u002F 1 bit per dot, 48 bytes a row; the height field is 16-bit.\n    let rows = min(bitmap.height, 65_535)\n    let raster = bitmap.rows.prefix(bitmap.widthBytes * rows)\n    return [\n        .write(speed(settings.speed)), .wait(0.03),      \u002F\u002F 1B 4E 0D n\n        .write(density(settings.darkness.density)),      \u002F\u002F 1B 4E 04 n\n        .wait(0.03),\n        .write(media(settings.media)), .wait(0.03),      \u002F\u002F 1F 11 n\n        \u002F\u002F GS v 0 with the full height, then the rows:\n        \u002F\u002F 128-byte writes, 20 ms apart.\n        .write(rasterHeader(widthBytes: bitmap.widthBytes, rows: rows)),\n        .stream(Data(raster), chunk: 128, pause: 0.02),\n        .wait(0.30),\n        .write(footer),                                  \u002F\u002F 1F F0 05 00 1F F0 03 00\n    ]\n}\n",[115,1237,1238,1259,1272,1277,1297,1320,1327,1356,1373,1386,1411,1416,1421,1446,1481,1495,1508,1514],{"__ignoreMap":140},[144,1239,1240,1243,1245,1248,1250,1253,1256],{"class":146,"line":147},[144,1241,1242],{"class":201},"static",[144,1244,1078],{"class":157},[144,1246,1247],{"class":161}," steps",[144,1249,166],{"class":165},[144,1251,1252],{"class":161},"for",[144,1254,1255],{"class":320}," bitmap",[144,1257,1258],{"class":165},": ThermalBitmap,\n",[144,1260,1261,1264,1267,1269],{"class":146,"line":154},[144,1262,1263],{"class":161},"                  settings",[144,1265,1266],{"class":165},": PrintSettings) ",[144,1268,202],{"class":201},[144,1270,1271],{"class":165}," [PrintStep] {\n",[144,1273,1274],{"class":146,"line":218},[144,1275,1276],{"class":150},"    \u002F\u002F 1 bit per dot, 48 bytes a row; the height field is 16-bit.\n",[144,1278,1279,1281,1284,1286,1289,1292,1295],{"class":146,"line":253},[144,1280,221],{"class":201},[144,1282,1283],{"class":165}," rows ",[144,1285,227],{"class":201},[144,1287,1288],{"class":379}," min",[144,1290,1291],{"class":165},"(bitmap.height, ",[144,1293,1294],{"class":667},"65_535",[144,1296,705],{"class":165},[144,1298,1299,1301,1304,1306,1309,1312,1315,1317],{"class":146,"line":288},[144,1300,221],{"class":201},[144,1302,1303],{"class":165}," raster ",[144,1305,227],{"class":201},[144,1307,1308],{"class":165}," bitmap.rows.",[144,1310,1311],{"class":379},"prefix",[144,1313,1314],{"class":165},"(bitmap.widthBytes ",[144,1316,242],{"class":201},[144,1318,1319],{"class":165}," rows)\n",[144,1321,1322,1324],{"class":146,"line":294},[144,1323,256],{"class":201},[144,1325,1326],{"class":165}," [\n",[144,1328,1329,1331,1334,1336,1339,1342,1345,1347,1350,1353],{"class":146,"line":301},[144,1330,509],{"class":165},[144,1332,1333],{"class":379},"write",[144,1335,166],{"class":165},[144,1337,1338],{"class":379},"speed",[144,1340,1341],{"class":165},"(settings.speed)), .",[144,1343,1344],{"class":379},"wait",[144,1346,166],{"class":165},[144,1348,1349],{"class":667},"0.03",[144,1351,1352],{"class":165},"),      ",[144,1354,1355],{"class":150},"\u002F\u002F 1B 4E 0D n\n",[144,1357,1358,1360,1362,1364,1367,1370],{"class":146,"line":307},[144,1359,509],{"class":165},[144,1361,1333],{"class":379},[144,1363,166],{"class":165},[144,1365,1366],{"class":379},"density",[144,1368,1369],{"class":165},"(settings.darkness.density)),      ",[144,1371,1372],{"class":150},"\u002F\u002F 1B 4E 04 n\n",[144,1374,1375,1377,1379,1381,1383],{"class":146,"line":344},[144,1376,509],{"class":165},[144,1378,1344],{"class":379},[144,1380,166],{"class":165},[144,1382,1349],{"class":667},[144,1384,1385],{"class":165},"),\n",[144,1387,1388,1390,1392,1394,1397,1400,1402,1404,1406,1408],{"class":146,"line":371},[144,1389,509],{"class":165},[144,1391,1333],{"class":379},[144,1393,166],{"class":165},[144,1395,1396],{"class":379},"media",[144,1398,1399],{"class":165},"(settings.media)), .",[144,1401,1344],{"class":379},[144,1403,166],{"class":165},[144,1405,1349],{"class":667},[144,1407,1352],{"class":165},[144,1409,1410],{"class":150},"\u002F\u002F 1F 11 n\n",[144,1412,1413],{"class":146,"line":401},[144,1414,1415],{"class":150},"        \u002F\u002F GS v 0 with the full height, then the rows:\n",[144,1417,1418],{"class":146,"line":424},[144,1419,1420],{"class":150},"        \u002F\u002F 128-byte writes, 20 ms apart.\n",[144,1422,1423,1425,1427,1429,1432,1434,1437,1440,1443],{"class":146,"line":452},[144,1424,509],{"class":165},[144,1426,1333],{"class":379},[144,1428,166],{"class":165},[144,1430,1431],{"class":379},"rasterHeader",[144,1433,166],{"class":165},[144,1435,1436],{"class":379},"widthBytes",[144,1438,1439],{"class":165},": bitmap.widthBytes, ",[144,1441,1442],{"class":379},"rows",[144,1444,1445],{"class":165},": rows)),\n",[144,1447,1448,1450,1453,1455,1458,1461,1464,1466,1469,1471,1474,1476,1479],{"class":146,"line":708},[144,1449,509],{"class":165},[144,1451,1452],{"class":379},"stream",[144,1454,166],{"class":165},[144,1456,1457],{"class":379},"Data",[144,1459,1460],{"class":165},"(raster), ",[144,1462,1463],{"class":379},"chunk",[144,1465,172],{"class":165},[144,1467,1468],{"class":667},"128",[144,1470,178],{"class":165},[144,1472,1473],{"class":379},"pause",[144,1475,172],{"class":165},[144,1477,1478],{"class":667},"0.02",[144,1480,1385],{"class":165},[144,1482,1484,1486,1488,1490,1493],{"class":146,"line":1483},15,[144,1485,509],{"class":165},[144,1487,1344],{"class":379},[144,1489,166],{"class":165},[144,1491,1492],{"class":667},"0.30",[144,1494,1385],{"class":165},[144,1496,1498,1500,1502,1505],{"class":146,"line":1497},16,[144,1499,509],{"class":165},[144,1501,1333],{"class":379},[144,1503,1504],{"class":165},"(footer),                                  ",[144,1506,1507],{"class":150},"\u002F\u002F 1F F0 05 00 1F F0 03 00\n",[144,1509,1511],{"class":146,"line":1510},17,[144,1512,1513],{"class":165},"    ]\n",[144,1515,1517],{"class":146,"line":1516},18,[144,1518,291],{"class":165},[11,1520,1521,1524,1525,1528,1529,1532],{},[17,1522,1523],{},"Третьи грабли — пиксели."," Чек — это список операций рисования, и одно SwiftUI-представление рисует их для экрана, PDF, картинки и принтера. Для принтера чек рендерится ровно ",[17,1526,1527],{},"один пиксель на точку",", 384 в ширину: картинку в 385 пикселей родное приложение принтера масштабирует, и от масштабирования размывается каждая буква. Шрифт для головки делается плотнее (чистый чёрный, никаких волосяных линий), а у карты маршрута своя обработка. Карта Apple превращается в «основу для печати»: цвет считается бумагой, выживает только то, что темнее своего окружения (улицы и их названия), а кварталы, парки и вода становятся белыми. Потом карта масштабируется до финального размера, и только тогда её дизерят. ",[17,1530,1531],{},"Дизеринг — на финальном размере, никогда до масштабирования",": сдизеренная картинка, отмасштабированная ещё раз, превращается в серую кашу.",[11,1534,1535],{},[1536,1537],"img",{"alt":1538,"src":1539},"Что на самом деле прожигает M110: один пиксель на точку, сдизеренная карта улиц, участки поездки и итог","\u002Fimages\u002Ffare_enough\u002Freceipt-map-dots.png",[11,1541,1542,1543,75],{},"Предпросмотр в приложении — ровно этот битмап, точка в точку. В симуляторе iOS нет Bluetooth, поэтому код принтера ещё и собирается в маленькую утилиту командной строки для Mac, с тем же самым файлом CoreBluetooth. И последний сюрприз: macOS убивает простой бинарник, который лезет в Bluetooth без собственной строки с объяснением, так что утилита живёт внутри минимальной обёртки ",[115,1544,1545],{},".app",[22,1547,1549],{"id":1548},"бонус-live-activity-которая-была-шире-экрана","Бонус: Live Activity, которая была шире экрана",[11,1551,1552,1553,1556,1557,1560,1561,1564],{},"На экране блокировки айфона карточка поездки была обрезана слева и с пустым отступом справа. На Apple Watch при этом всё выглядело идеально. Виноваты были часы поездки: ",[115,1554,1555],{},"Text(timerInterval:)",", считающий до ",[115,1558,1559],{},"Date.distantFuture",", занимает место ",[17,1562,1563],{},"под самое длинное время, которое он когда-либо сможет показать",", а в строке, которая держала свою идеальную ширину, он выталкивал всю карточку за край экрана.",[11,1566,1567],{},"Решение: считать до суток (система всё равно завершает Live Activity через 8 часов) и держать ширину «0:00:00» скрытой заглушкой, а таймер рисовать поверх.",[135,1569,1571],{"className":137,"code":1570,"language":139,"meta":140,"style":140},"let aDay = start.addingTimeInterval(24 * 3600)\nText(verbatim: \"0:00:00\")          \u002F\u002F the width the clock may take\n    .hidden()\n    .overlay(alignment: .leading) {\n        Text(timerInterval: start...aDay, countsDown: false)\n    }\n    .monospacedDigit()\n",[115,1572,1573,1602,1624,1634,1649,1677,1681],{"__ignoreMap":140},[144,1574,1575,1578,1581,1583,1586,1589,1591,1594,1597,1600],{"class":146,"line":147},[144,1576,1577],{"class":201},"let",[144,1579,1580],{"class":165}," aDay ",[144,1582,227],{"class":201},[144,1584,1585],{"class":165}," start.",[144,1587,1588],{"class":379},"addingTimeInterval",[144,1590,166],{"class":165},[144,1592,1593],{"class":667},"24",[144,1595,1596],{"class":201}," *",[144,1598,1599],{"class":667}," 3600",[144,1601,705],{"class":165},[144,1603,1604,1607,1609,1612,1614,1618,1621],{"class":146,"line":154},[144,1605,1606],{"class":379},"Text",[144,1608,166],{"class":165},[144,1610,1611],{"class":379},"verbatim",[144,1613,172],{"class":165},[144,1615,1617],{"class":1616},"s_Ekj","\"0:00:00\"",[144,1619,1620],{"class":165},")          ",[144,1622,1623],{"class":150},"\u002F\u002F the width the clock may take\n",[144,1625,1626,1629,1632],{"class":146,"line":218},[144,1627,1628],{"class":165},"    .",[144,1630,1631],{"class":379},"hidden",[144,1633,992],{"class":165},[144,1635,1636,1638,1641,1643,1646],{"class":146,"line":253},[144,1637,1628],{"class":165},[144,1639,1640],{"class":379},"overlay",[144,1642,166],{"class":165},[144,1644,1645],{"class":379},"alignment",[144,1647,1648],{"class":165},": .leading) {\n",[144,1650,1651,1654,1656,1659,1662,1665,1668,1671,1673,1675],{"class":146,"line":288},[144,1652,1653],{"class":379},"        Text",[144,1655,166],{"class":165},[144,1657,1658],{"class":379},"timerInterval",[144,1660,1661],{"class":165},": start",[144,1663,1664],{"class":201},"...",[144,1666,1667],{"class":165},"aDay, ",[144,1669,1670],{"class":379},"countsDown",[144,1672,172],{"class":165},[144,1674,1028],{"class":667},[144,1676,705],{"class":165},[144,1678,1679],{"class":146,"line":294},[144,1680,1180],{"class":165},[144,1682,1683,1685,1688],{"class":146,"line":301},[144,1684,1628],{"class":165},[144,1686,1687],{"class":379},"monospacedDigit",[144,1689,992],{"class":165},[11,1691,1692],{},"На Mac это не воспроизводилось, потому что macOS размечает таймер по времени, которое он показывает прямо сейчас. Так что UI-тест фотографирует карточку на экране блокировки симулятора.",[22,1694,1696],{"id":1695},"вот-и-всё","Вот и всё",[11,1698,1699],{},"Fare Enough говорит на английском, испанском, немецком и польском и знает тарифы Испании, Германии, Польши, США и Великобритании. Бесплатно, без рекламы и без аккаунта. Четырёхлетний я с той VHS-кассеты назвал бы это неплохим началом.",[40,1701,1702,1708],{},[43,1703,1704,1705],{},"Сайт: ",[69,1706,74],{"href":71,"rel":1707},[73],[43,1709,1710,1711],{},"Идеи, баги, тариф твоего города: ",[69,1712,1714],{"href":1713},"mailto:info@slnt-opp.xyz","info@slnt-opp.xyz",[11,1716,1717],{},"Если прокатишься с ним куда-нибудь интересно, пришли мне чек. Напечатанные считаются вдвойне.",[498,1719,1720],{},"html pre.shiki code .snpHw, html code.shiki .snpHw{--shiki-default:#88846F}html pre.shiki code .sOx1s, html code.shiki .sOx1s{--shiki-default:#66D9EF;--shiki-default-font-style:italic}html pre.shiki code .sHkqI, html code.shiki .sHkqI{--shiki-default:#A6E22E}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .sW0Xf, html code.shiki .sW0Xf{--shiki-default:#FD971F;--shiki-default-font-style:italic}html pre.shiki code .sYf5A, html code.shiki .sYf5A{--shiki-default:#66D9EF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}html pre.shiki code .szGx7, html code.shiki .szGx7{--shiki-default:#FD971F}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}",{"title":140,"searchDepth":154,"depth":154,"links":1722},[1723,1724,1725,1726,1727,1728,1729],{"id":24,"depth":154,"text":25},{"id":34,"depth":154,"text":35},{"id":97,"depth":154,"text":98},{"id":873,"depth":154,"text":874},{"id":1208,"depth":154,"text":1209},{"id":1548,"depth":154,"text":1549},{"id":1695,"depth":154,"text":1696},"2026-09-25","Fare Enough превращает айфон в таксометр: тарифы настоящих городов, старый добрый аппаратный счётчик, если повернуть телефон боком, и чеки на карманном термопринтере. Рассказываю, чему меня научили Skip, фоновая геолокация и Bluetooth-принтеры.",false,"md","\u002Fimages\u002Ffare_enough\u002Fcover.jpg",{},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Ffare_enough",{"title":5,"description":1731},"ru\u002Fposts\u002Fcode_n_shit\u002Ffare_enough",[115,139,1740],"ios","fare-enough","_g7V82t5gHAUJrhT6FVceepMWKxxbI_7dQzep_Z8aVU",[1744],{"locale":1745,"path":1746,"title":1747},"en","\u002Fposts\u002Fcode_n_shit\u002Ffare_enough","I made a taxi meter for my iPhone. For fun.",1790369235096]