[{"data":1,"prerenderedAt":1750},["ShallowReactive",2],{"page:\u002Fposts\u002Fcode_n_shit\u002Ffare_enough":3,"translations:\u002Fposts\u002Fcode_n_shit\u002Ffare_enough":1745},{"id":4,"title":5,"author":6,"body":7,"date":1732,"description":1733,"draft":1734,"extension":1735,"image":1736,"listed":297,"meta":1737,"navigation":297,"path":1738,"seo":1739,"stem":1740,"tags":1741,"theme":1743,"toc":297,"__hash__":1744},"content_en\u002Fposts\u002Fcode_n_shit\u002Ffare_enough.md","I made a taxi meter for my iPhone. For fun.","slnt_opp",{"type":8,"value":9,"toc":1723},"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,830,869,872,876,883,1004,1007,1054,1061,1064,1204,1207,1211,1214,1228,1234,1520,1534,1541,1548,1552,1567,1570,1692,1695,1699,1702,1717,1720],[11,12,13],"p",{},"Ever sat in a taxi, watched the red digits tick, and wondered what the same drive would cost in Madrid? Or in a New York yellow cab? I did, so I built a meter.",[11,15,16,20],{},[17,18,19],"strong",{},"Fare Enough"," turns an iPhone into a taxi meter, just for fun. You pick a real city's published tariff, drive, and the app shows what the ride would cost there: distance from GPS, waiting time, night rates, extras like luggage. At the end you get a receipt.",[22,23,25],"h2",{"id":24},"why-a-taxi-meter-of-all-things","Why a taxi meter, of all things",[11,27,28],{},"Somewhere there's a VHS tape (remember those, kids? 📼) of three- or four-year-old me in kindergarten, being asked what I want to be when I grow up. The answer comes without a second of doubt: a taxi driver. Life had other plans and I had to settle for IT, but I still absolutely love driving.",[11,30,31],{},"The other half of the story: LARP is my middle name. I owned a receipt printer before I owned a car. So far I've mostly been LARPing as a Dutchman, and now the kindergarten dream can be stitched into the act too. Four-year-old me would be very proud. Nobody else has to be.",[22,33,35],{"id":34},"whats-in-it","What's in it",[11,37,38],{},"It's just an app, but here's what it does:",[40,41,42,46,49,52,55,58],"ul",{},[43,44,45],"li",{},"Tariffs from 19 cities in 5 countries: Madrid, Barcelona, Berlin, Munich, Warsaw, Kraków, New York, Chicago, London, Edinburgh and a few more, each copied from the city's published rates. Night and holiday rates switch by themselves.",[43,47,48],{},"Turn the phone on its side and it becomes an old-school meter, with glowing digits and chunky keys.",[43,50,51],{},"A receipt at the end of the ride: a PDF, a picture, or paper from a tiny Bluetooth printer.",[43,53,54],{},"The fare on the Lock Screen while you drive. CarPlay is built and waiting for Apple's permission.",[43,56,57],{},"Discounts for friends, because you wouldn't bill a friend the real price, even for fun.",[43,59,60],{},"Free, with no ads, no account and no analytics. Nothing leaves the phone except requests to Apple Maps.",[11,62,63,64,67,68,75],{},"It's waiting for App Store review as I write this (as ",[17,65,66],{},"Fare Enough Meter",": \"Fare Enough\" was taken). ",[69,70,74],"a",{"href":71,"rel":72},"https:\u002F\u002Ffare.slnt-opp.xyz",[73],"nofollow","fare.slnt-opp.xyz"," will have the link the day it's out.",[77,78],"gallery",{":images":79},"[{\"src\":\"\u002Fimages\u002Ffare_enough\u002Fapp-meter.jpg\",\"alt\":\"The meter during a ride in New York\"},{\"src\":\"\u002Fimages\u002Ffare_enough\u002Fapp-receipt.jpg\",\"alt\":\"The ride’s receipt, with its route\"}]",[11,81,82],{},"Here's the meter's face to play with: a web copy, drawn with the same geometry as the app. Press Start.",[84,85],"fare-enough-meter",{},[87,88,89],"note",{},[11,90,91],{},"Fare Enough is not a certified or calibrated taximeter, and it isn't for professional drivers. Every receipt says so at the bottom: \"Not a fiscal receipt. Made with Fare Enough.\"",[11,93,94],{},"Now the fun part: the three things that took the most figuring out.",[22,96,98],{"id":97},"skip-and-a-meter-that-has-to-survive-android","Skip, and a meter that has to survive Android",[11,100,101,102,107],{},"The app is SwiftUI today. Android comes later with ",[69,103,106],{"href":104,"rel":105},"https:\u002F\u002Fskip.dev",[73],"Skip",", whose Fuse mode compiles Swift natively for Android and renders SwiftUI through Jetpack Compose. The plan is that the port adds a handful of files and moves nothing. So the app has been written for it from the first commit: every Apple SDK call sits in one guarded platform file behind a protocol, and the core (tariffs, the meter's arithmetic, receipts) imports Foundation and nothing else.",[11,109,110,113,114,118,119,122,123,126,127,126,130,133],{},[17,111,112],{},"How hard is a game-style view under those rules? Drawing turned out to be the easy part."," On Skip, ",[115,116,117],"code",{},"Shape"," and ",[115,120,121],{},"Path"," are the drawing API that crosses over. There's no ",[115,124,125],{},"Canvas",", no ",[115,128,129],{},"TimelineView",[115,131,132],{},"ImageRenderer"," in shared code, and nothing that carries meaning may depend on blend modes or 3D rotation. So every digit is geometry: hexagonal bars computed from the digit's height, with the italic slant built into the point mapping instead of a transform applied afterwards.",[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],{},"The glow is four layers of the same path, like a real LED: the ghost (every segment, barely there, the \"88.88\" look), the bloom (the lit path, blurred), the body, and a thinner \"hot core\" on top. Each layer is ",[17,459,460],{},"one"," ",[115,463,121],{}," for the whole display, so it's one fill and one blur however many digits there are. The blur is decoration only: if Compose draws it plainer one day, the meter loses shine, not information.",[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],{},"After that, a skin is just colours on the same geometry: red LED, amber, green, a cyan VFD and a grey LCD (plus a vintage one with rolling drums).",[77,715],{":images":716},"[{\"src\":\"\u002Fimages\u002Ffare_enough\u002Fmeter-madrid.jpg\",\"alt\":\"Red LED, mid-ride on Madrid’s tariff 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\":\"Amber\"},{\"src\":\"\u002Fimages\u002Ffare_enough\u002Fskin-green.jpg\",\"alt\":\"Green\"}]",[11,718,719,720,723,724,726,727,730,731,734],{},"The blinking colon and the power-on lamp test run on a small ",[115,721,722],{},"Task"," loop at 2 Hz, only while the meter is on screen, because ",[115,725,129],{}," doesn't exist on Skip. The keys needed a trick too: Skip has no custom ",[115,728,729],{},"ButtonStyle"," (only ",[115,732,733],{},"PrimitiveButtonStyle","), so the pressed look lives in one compatibility shim with an Android branch:",[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,791,798,803,819,826],{"__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,785,788],{"class":146,"line":218},[144,779,781],{"class":780},"szGx7","    self",[144,783,784],{"class":165},".",[144,786,787],{"class":379},"buttonStyle",[144,789,790],{"class":165},"(.plain)\n",[144,792,793,795],{"class":146,"line":253},[144,794,762],{"class":165},[144,796,797],{"class":201},"else\n",[144,799,800],{"class":146,"line":288},[144,801,802],{"class":150},"    \u002F\u002F Pressed: brightness −0.14 and 1 pt down.\n",[144,804,805,807,809,811,813,816],{"class":146,"line":294},[144,806,781],{"class":780},[144,808,784],{"class":165},[144,810,787],{"class":379},[144,812,166],{"class":165},[144,814,815],{"class":379},"MeterKeyPressStyle",[144,817,818],{"class":165},"())\n",[144,820,821,823],{"class":146,"line":301},[144,822,762],{"class":165},[144,824,825],{"class":201},"endif\n",[144,827,828],{"class":146,"line":307},[144,829,291],{"class":165},[11,831,832,835,836,839,840,844,845,848,849,852,853,856,857,860,861,864,865,868],{},[17,833,834],{},"The hard part is everything that compiles fine on iOS and only breaks on Android",", weeks later. ",[115,837,838],{},"Text(\"Fare\")"," looks the string up again in the ",[841,842,843],"em",{},"device's"," language, so one row can mix two languages: it has to be ",[115,846,847],{},"Text(verbatim: loc(\"Fare\"))",". ",[115,850,851],{},"didSet"," on an ",[115,854,855],{},"@Observable"," property never fires in the app (it does in unit tests, which is how it hides). ",[115,858,859],{},"ForEach"," over anything that isn't an ",[115,862,863],{},"Array"," aborts. Only 73 SF Symbols have an Android icon, and any other name silently draws nothing. ",[115,866,867],{},".foregroundStyle(.tertiary)"," is a crash. None of that is caught by the compiler, so each rule is a test that reads the Swift sources as text and fails the build with the fix in its message. A rule that only lives in a document lasts about six weeks.",[11,870,871],{},"To be fair: the Android build doesn't exist yet. This whole section is a bet that it will be boring when it comes.",[22,873,875],{"id":874},"background-location-or-how-a-drive-became-waiting-time","Background location, or how a drive became waiting time",[11,877,878,879,882],{},"A meter has to keep measuring with the phone locked in a car mount, and it has to ask for as little as possible: ",[17,880,881],{},"When In Use"," permission only, never Always. That works, but only when every piece is in place:",[135,884,886],{"className":137,"code":885,"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,887,888,893,903,913,923,928,938,943,953,963,967,976,981,994],{"__ignoreMap":140},[144,889,890],{"class":146,"line":147},[144,891,892],{"class":150},"\u002F\u002F A car on roads (.otherNavigation is boats and trains).\n",[144,894,895,898,900],{"class":146,"line":154},[144,896,897],{"class":165},"manager.activityType ",[144,899,227],{"class":201},[144,901,902],{"class":165}," .automotiveNavigation\n",[144,904,905,908,910],{"class":146,"line":218},[144,906,907],{"class":165},"manager.desiredAccuracy ",[144,909,227],{"class":201},[144,911,912],{"class":165}," kCLLocationAccuracyBestForNavigation\n",[144,914,915,918,920],{"class":146,"line":253},[144,916,917],{"class":165},"manager.distanceFilter ",[144,919,227],{"class":201},[144,921,922],{"class":165}," kCLDistanceFilterNone\n",[144,924,925],{"class":146,"line":288},[144,926,927],{"class":150},"\u002F\u002F true = iOS stops a meter waiting in traffic, for good.\n",[144,929,930,933,935],{"class":146,"line":294},[144,931,932],{"class":165},"manager.pausesLocationUpdatesAutomatically ",[144,934,227],{"class":201},[144,936,937],{"class":667}," false\n",[144,939,940],{"class":146,"line":301},[144,941,942],{"class":150},"\u002F\u002F Read from the *built* Info.plist: without the mode, this line kills the app.\n",[144,944,945,947,950],{"class":146,"line":307},[144,946,765],{"class":201},[144,948,949],{"class":780}," Self",[144,951,952],{"class":165},".hasLocationBackgroundMode {\n",[144,954,955,958,960],{"class":146,"line":344},[144,956,957],{"class":165},"    manager.allowsBackgroundLocationUpdates ",[144,959,227],{"class":201},[144,961,962],{"class":667}," true\n",[144,964,965],{"class":146,"line":371},[144,966,291],{"class":165},[144,968,969,972,974],{"class":146,"line":401},[144,970,971],{"class":165},"manager.showsBackgroundLocationIndicator ",[144,973,227],{"class":201},[144,975,962],{"class":667},[144,977,978],{"class":146,"line":424},[144,979,980],{"class":150},"\u002F\u002F iOS 17+: held while the meter runs.\n",[144,982,983,986,988,991],{"class":146,"line":452},[144,984,985],{"class":165},"backgroundSession ",[144,987,227],{"class":201},[144,989,990],{"class":379}," CLBackgroundActivitySession",[144,992,993],{"class":165},"()\n",[144,995,996,999,1002],{"class":146,"line":708},[144,997,998],{"class":165},"manager.",[144,1000,1001],{"class":379},"startUpdatingLocation",[144,1003,993],{"class":165},[11,1005,1006],{},"The gotchas, in the order they bit:",[40,1008,1009,1019,1031,1044],{},[43,1010,1011,1014,1015,1018],{},[17,1012,1013],{},"\"Vehicle mode\""," is ",[115,1016,1017],{},"activityType = .automotiveNavigation",". It tells Core Location this is a car on roads, which changes how iOS filters the fixes and when it pauses them.",[43,1020,1021,1030],{},[17,1022,1023,1026,1027,784],{},[115,1024,1025],{},"pausesLocationUpdatesAutomatically"," must be ",[115,1028,1029],{},"false"," Otherwise iOS decides a car waiting at lights has arrived and pauses updates, and a paused background app doesn't get them back until it's opened again. Waiting time is exactly when a meter earns money.",[43,1032,1033,461,1036,1039,1040,1043],{},[17,1034,1035],{},"The background mode has to be in the Info.plist file.",[115,1037,1038],{},"INFOPLIST_KEY_UIBackgroundModes"," in build settings silently never reaches the built app, and ",[115,1041,1042],{},"allowsBackgroundLocationUpdates = true"," without the mode doesn't throw: it kills the app. So the app checks its own built bundle first, and a script checks the built app before every release.",[43,1045,1046,1049,1050,1053],{},[17,1047,1048],{},"The first fix is often old news."," Right after ",[115,1051,1052],{},"startUpdatingLocation()"," iOS hands over its cached position, which can be the last drop-off hundreds of metres away. Fixes older than 30 s are dropped.",[11,1055,1056,1057,1060],{},"Then came the real field test. A drive in Magdeburg came back as ",[17,1058,1059],{},"13 min 39 s of waiting time and no distance line",", even though 1,15 km had been measured. The phone was handing its fixes over late, seconds behind or in bursts while locked, and the meter's clock billed every second as it passed, at the speed of the last fix it had: zero. Every second looked like standing still.",[11,1062,1063],{},"The fix is to let the fixes bill themselves. While they keep arriving, the clock stays out of it:",[135,1065,1067],{"className":137,"code":1066,"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,1068,1069,1094,1120,1125,1130,1155,1172,1177,1182,1187,1200],{"__ignoreMap":140},[144,1070,1071,1074,1077,1080,1083,1085,1088,1091],{"class":146,"line":147},[144,1072,1073],{"class":201},"public",[144,1075,1076],{"class":201}," mutating",[144,1078,1079],{"class":157}," func",[144,1081,1082],{"class":161}," tick",[144,1084,166],{"class":165},[144,1086,1087],{"class":161},"at",[144,1089,1090],{"class":320}," date",[144,1092,1093],{"class":165},": Date) {\n",[144,1095,1096,1099,1102,1105,1108,1111,1114,1117],{"class":146,"line":154},[144,1097,1098],{"class":201},"    guard",[144,1100,1101],{"class":165}," state.status ",[144,1103,1104],{"class":201},"==",[144,1106,1107],{"class":165}," .hired ",[144,1109,1110],{"class":201},"else",[144,1112,1113],{"class":165}," { ",[144,1115,1116],{"class":201},"return",[144,1118,1119],{"class":165}," }\n",[144,1121,1122],{"class":146,"line":218},[144,1123,1124],{"class":150},"    \u002F\u002F Fixes are still coming, however late: each one bills\n",[144,1126,1127],{"class":146,"line":253},[144,1128,1129],{"class":150},"    \u002F\u002F its own seconds, at its own speed, when it lands.\n",[144,1131,1132,1135,1138,1141,1143,1146,1149,1152],{"class":146,"line":288},[144,1133,1134],{"class":201},"    if",[144,1136,1137],{"class":201}," let",[144,1139,1140],{"class":165}," arrived ",[144,1142,227],{"class":201},[144,1144,1145],{"class":165}," state.lastFixArrival, state.gps.lastFix ",[144,1147,1148],{"class":201},"!=",[144,1150,1151],{"class":667}," nil",[144,1153,1154],{"class":165},",\n",[144,1156,1157,1160,1163,1166,1169],{"class":146,"line":294},[144,1158,1159],{"class":165},"       date.",[144,1161,1162],{"class":379},"timeIntervalSince",[144,1164,1165],{"class":165},"(arrived) ",[144,1167,1168],{"class":201},"\u003C=",[144,1170,1171],{"class":165}," state.gps.gapSeconds {\n",[144,1173,1174],{"class":146,"line":301},[144,1175,1176],{"class":201},"        return\n",[144,1178,1179],{"class":146,"line":307},[144,1180,1181],{"class":165},"    }\n",[144,1183,1184],{"class":146,"line":344},[144,1185,1186],{"class":150},"    \u002F\u002F The GPS has been silent for 10 s: now the clock bills waiting.\n",[144,1188,1189,1192,1194,1197],{"class":146,"line":371},[144,1190,1191],{"class":379},"    advance",[144,1193,166],{"class":165},[144,1195,1196],{"class":379},"to",[144,1198,1199],{"class":165},": date)\n",[144,1201,1202],{"class":146,"line":401},[144,1203,291],{"class":165},[11,1205,1206],{},"A late fix re-bills from the meter as it stood at the previous fix, and the fare on the screen never drops while it does. Replays in the simulator have to run at 1×: iOS stamps a fix when it arrives, so a sped-up replay is a faster ride, not the same ride sooner. The Developer page now also counts how the phone delivered its fixes (how many arrived in the background, how late, the longest silence), because a log you can read on the phone itself beats guessing.",[22,1208,1210],{"id":1209},"a-receipt-printer-for-the-price-of-a-pizza","A receipt printer for the price of a pizza",[11,1212,1213],{},"The Phomemo M110 is a pocket label printer: 203 dpi, a 384-dot head (48 mm), Bluetooth LE. The byte sequence comes from what two open-source drivers send (pyphomemo and phomymo, thank you both), and from a lot of printed test pages.",[11,1215,1216,1219,1220,1223,1224,1227],{},[17,1217,1218],{},"Finding it is the first trap."," Most M110s advertise nothing but their serial number, like ",[115,1221,1222],{},"Q119G18Q0830029",": Q, three digits, a letter, two digits, a letter, seven digits. The printer's services, ",[115,1225,1226],{},"FF00"," and friends, are generic serial services that speakers, lamps and trackers use too. Matching on them once listed a neighbour's Bluetooth speaker as an M110. So the app recognises printers by name only.",[11,1229,1230,1233],{},[17,1231,1232],{},"The second trap is timing."," Sent as one stream, the job makes the printer flash its light and drop everything. It wants the settings as separate writes with pauses, one raster header carrying the full height, the rows in small chunks, and the footer after a breath:",[135,1235,1237],{"className":137,"code":1236,"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,1238,1239,1260,1273,1278,1298,1321,1328,1357,1374,1387,1412,1417,1422,1447,1482,1496,1509,1515],{"__ignoreMap":140},[144,1240,1241,1244,1246,1249,1251,1254,1257],{"class":146,"line":147},[144,1242,1243],{"class":201},"static",[144,1245,1079],{"class":157},[144,1247,1248],{"class":161}," steps",[144,1250,166],{"class":165},[144,1252,1253],{"class":161},"for",[144,1255,1256],{"class":320}," bitmap",[144,1258,1259],{"class":165},": ThermalBitmap,\n",[144,1261,1262,1265,1268,1270],{"class":146,"line":154},[144,1263,1264],{"class":161},"                  settings",[144,1266,1267],{"class":165},": PrintSettings) ",[144,1269,202],{"class":201},[144,1271,1272],{"class":165}," [PrintStep] {\n",[144,1274,1275],{"class":146,"line":218},[144,1276,1277],{"class":150},"    \u002F\u002F 1 bit per dot, 48 bytes a row; the height field is 16-bit.\n",[144,1279,1280,1282,1285,1287,1290,1293,1296],{"class":146,"line":253},[144,1281,221],{"class":201},[144,1283,1284],{"class":165}," rows ",[144,1286,227],{"class":201},[144,1288,1289],{"class":379}," min",[144,1291,1292],{"class":165},"(bitmap.height, ",[144,1294,1295],{"class":667},"65_535",[144,1297,705],{"class":165},[144,1299,1300,1302,1305,1307,1310,1313,1316,1318],{"class":146,"line":288},[144,1301,221],{"class":201},[144,1303,1304],{"class":165}," raster ",[144,1306,227],{"class":201},[144,1308,1309],{"class":165}," bitmap.rows.",[144,1311,1312],{"class":379},"prefix",[144,1314,1315],{"class":165},"(bitmap.widthBytes ",[144,1317,242],{"class":201},[144,1319,1320],{"class":165}," rows)\n",[144,1322,1323,1325],{"class":146,"line":294},[144,1324,256],{"class":201},[144,1326,1327],{"class":165}," [\n",[144,1329,1330,1332,1335,1337,1340,1343,1346,1348,1351,1354],{"class":146,"line":301},[144,1331,509],{"class":165},[144,1333,1334],{"class":379},"write",[144,1336,166],{"class":165},[144,1338,1339],{"class":379},"speed",[144,1341,1342],{"class":165},"(settings.speed)), .",[144,1344,1345],{"class":379},"wait",[144,1347,166],{"class":165},[144,1349,1350],{"class":667},"0.03",[144,1352,1353],{"class":165},"),      ",[144,1355,1356],{"class":150},"\u002F\u002F 1B 4E 0D n\n",[144,1358,1359,1361,1363,1365,1368,1371],{"class":146,"line":307},[144,1360,509],{"class":165},[144,1362,1334],{"class":379},[144,1364,166],{"class":165},[144,1366,1367],{"class":379},"density",[144,1369,1370],{"class":165},"(settings.darkness.density)),      ",[144,1372,1373],{"class":150},"\u002F\u002F 1B 4E 04 n\n",[144,1375,1376,1378,1380,1382,1384],{"class":146,"line":344},[144,1377,509],{"class":165},[144,1379,1345],{"class":379},[144,1381,166],{"class":165},[144,1383,1350],{"class":667},[144,1385,1386],{"class":165},"),\n",[144,1388,1389,1391,1393,1395,1398,1401,1403,1405,1407,1409],{"class":146,"line":371},[144,1390,509],{"class":165},[144,1392,1334],{"class":379},[144,1394,166],{"class":165},[144,1396,1397],{"class":379},"media",[144,1399,1400],{"class":165},"(settings.media)), .",[144,1402,1345],{"class":379},[144,1404,166],{"class":165},[144,1406,1350],{"class":667},[144,1408,1353],{"class":165},[144,1410,1411],{"class":150},"\u002F\u002F 1F 11 n\n",[144,1413,1414],{"class":146,"line":401},[144,1415,1416],{"class":150},"        \u002F\u002F GS v 0 with the full height, then the rows:\n",[144,1418,1419],{"class":146,"line":424},[144,1420,1421],{"class":150},"        \u002F\u002F 128-byte writes, 20 ms apart.\n",[144,1423,1424,1426,1428,1430,1433,1435,1438,1441,1444],{"class":146,"line":452},[144,1425,509],{"class":165},[144,1427,1334],{"class":379},[144,1429,166],{"class":165},[144,1431,1432],{"class":379},"rasterHeader",[144,1434,166],{"class":165},[144,1436,1437],{"class":379},"widthBytes",[144,1439,1440],{"class":165},": bitmap.widthBytes, ",[144,1442,1443],{"class":379},"rows",[144,1445,1446],{"class":165},": rows)),\n",[144,1448,1449,1451,1454,1456,1459,1462,1465,1467,1470,1472,1475,1477,1480],{"class":146,"line":708},[144,1450,509],{"class":165},[144,1452,1453],{"class":379},"stream",[144,1455,166],{"class":165},[144,1457,1458],{"class":379},"Data",[144,1460,1461],{"class":165},"(raster), ",[144,1463,1464],{"class":379},"chunk",[144,1466,172],{"class":165},[144,1468,1469],{"class":667},"128",[144,1471,178],{"class":165},[144,1473,1474],{"class":379},"pause",[144,1476,172],{"class":165},[144,1478,1479],{"class":667},"0.02",[144,1481,1386],{"class":165},[144,1483,1485,1487,1489,1491,1494],{"class":146,"line":1484},15,[144,1486,509],{"class":165},[144,1488,1345],{"class":379},[144,1490,166],{"class":165},[144,1492,1493],{"class":667},"0.30",[144,1495,1386],{"class":165},[144,1497,1499,1501,1503,1506],{"class":146,"line":1498},16,[144,1500,509],{"class":165},[144,1502,1334],{"class":379},[144,1504,1505],{"class":165},"(footer),                                  ",[144,1507,1508],{"class":150},"\u002F\u002F 1F F0 05 00 1F F0 03 00\n",[144,1510,1512],{"class":146,"line":1511},17,[144,1513,1514],{"class":165},"    ]\n",[144,1516,1518],{"class":146,"line":1517},18,[144,1519,291],{"class":165},[11,1521,1522,1525,1526,1529,1530,1533],{},[17,1523,1524],{},"The third trap is pixels."," The receipt is a list of drawing operations, and one SwiftUI view draws them for the screen, the PDF, the picture and the printer. For the printer it's rendered at exactly ",[17,1527,1528],{},"one pixel per dot",", 384 wide: a 385-pixel image gets scaled by the printer's own app, and the scaling blurs every letter. The type is made firmer for the head (pure black, no hairlines), and the route map gets its own treatment. Apple's map is turned into a \"print base\": colour counts as paper, only what's darker than its neighbourhood survives (streets and their names), and blocks, parks and water go white. Then it's scaled to its final size and dithered. ",[17,1531,1532],{},"Dither at the final size, never before scaling",": a dithered picture scaled again turns to grey mush.",[11,1535,1536],{},[1537,1538],"img",{"alt":1539,"src":1540},"What the M110 actually burns: one pixel per dot, the dithered street map, the legs and the total","\u002Fimages\u002Ffare_enough\u002Freceipt-map-dots.png",[11,1542,1543,1544,1547],{},"The preview in the app is that exact bitmap, dot for dot. The iOS Simulator has no Bluetooth, so the printer code also compiles into a tiny command-line tool for the Mac, sharing the very same CoreBluetooth file. One last surprise: macOS kills a plain binary that touches Bluetooth without a usage string of its own, so the tool runs inside a minimal ",[115,1545,1546],{},".app"," wrapper.",[22,1549,1551],{"id":1550},"bonus-the-live-activity-that-was-wider-than-the-screen","Bonus: the Live Activity that was wider than the screen",[11,1553,1554,1555,1558,1559,1562,1563,1566],{},"On the iPhone's Lock Screen the ride's card was cut off on the left and padded on the right. On the Apple Watch it looked perfect. The culprit was the ride's clock: ",[115,1556,1557],{},"Text(timerInterval:)"," counting up to ",[115,1560,1561],{},"Date.distantFuture"," is laid out ",[17,1564,1565],{},"as wide as the longest time it could ever show",", and sitting in a row that kept its ideal width, it pushed the whole card past the edge of the screen.",[11,1568,1569],{},"The fix: count to a day (the system ends a Live Activity after 8 hours anyway) and hold the width of \"0:00:00\" with a hidden placeholder, the timer drawn on top.",[135,1571,1573],{"className":137,"code":1572,"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,1574,1575,1604,1626,1636,1651,1679,1683],{"__ignoreMap":140},[144,1576,1577,1580,1583,1585,1588,1591,1593,1596,1599,1602],{"class":146,"line":147},[144,1578,1579],{"class":201},"let",[144,1581,1582],{"class":165}," aDay ",[144,1584,227],{"class":201},[144,1586,1587],{"class":165}," start.",[144,1589,1590],{"class":379},"addingTimeInterval",[144,1592,166],{"class":165},[144,1594,1595],{"class":667},"24",[144,1597,1598],{"class":201}," *",[144,1600,1601],{"class":667}," 3600",[144,1603,705],{"class":165},[144,1605,1606,1609,1611,1614,1616,1620,1623],{"class":146,"line":154},[144,1607,1608],{"class":379},"Text",[144,1610,166],{"class":165},[144,1612,1613],{"class":379},"verbatim",[144,1615,172],{"class":165},[144,1617,1619],{"class":1618},"s_Ekj","\"0:00:00\"",[144,1621,1622],{"class":165},")          ",[144,1624,1625],{"class":150},"\u002F\u002F the width the clock may take\n",[144,1627,1628,1631,1634],{"class":146,"line":218},[144,1629,1630],{"class":165},"    .",[144,1632,1633],{"class":379},"hidden",[144,1635,993],{"class":165},[144,1637,1638,1640,1643,1645,1648],{"class":146,"line":253},[144,1639,1630],{"class":165},[144,1641,1642],{"class":379},"overlay",[144,1644,166],{"class":165},[144,1646,1647],{"class":379},"alignment",[144,1649,1650],{"class":165},": .leading) {\n",[144,1652,1653,1656,1658,1661,1664,1667,1670,1673,1675,1677],{"class":146,"line":288},[144,1654,1655],{"class":379},"        Text",[144,1657,166],{"class":165},[144,1659,1660],{"class":379},"timerInterval",[144,1662,1663],{"class":165},": start",[144,1665,1666],{"class":201},"...",[144,1668,1669],{"class":165},"aDay, ",[144,1671,1672],{"class":379},"countsDown",[144,1674,172],{"class":165},[144,1676,1029],{"class":667},[144,1678,705],{"class":165},[144,1680,1681],{"class":146,"line":294},[144,1682,1181],{"class":165},[144,1684,1685,1687,1690],{"class":146,"line":301},[144,1686,1630],{"class":165},[144,1688,1689],{"class":379},"monospacedDigit",[144,1691,993],{"class":165},[11,1693,1694],{},"The Mac couldn't reproduce it, because macOS lays a timer out by the time it shows right now. So a UI test photographs the card on the simulator's Lock Screen.",[22,1696,1698],{"id":1697},"thats-it","That's it",[11,1700,1701],{},"Fare Enough speaks English, Spanish, German and Polish, and knows tariffs from Spain, Germany, Poland, the US and the UK. It's free, with no ads and no account. The four-year-old on that VHS tape would call it a start.",[40,1703,1704,1710],{},[43,1705,1706,1707],{},"Website: ",[69,1708,74],{"href":71,"rel":1709},[73],[43,1711,1712,1713],{},"Ideas, bugs, your city's tariff: ",[69,1714,1716],{"href":1715},"mailto:info@slnt-opp.xyz","info@slnt-opp.xyz",[11,1718,1719],{},"If you take it for a drive somewhere interesting, send me the receipt. Printed ones count double.",[498,1721,1722],{},"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":1724},[1725,1726,1727,1728,1729,1730,1731],{"id":24,"depth":154,"text":25},{"id":34,"depth":154,"text":35},{"id":97,"depth":154,"text":98},{"id":874,"depth":154,"text":875},{"id":1209,"depth":154,"text":1210},{"id":1550,"depth":154,"text":1551},{"id":1697,"depth":154,"text":1698},"2026-09-25","Fare Enough turns an iPhone into a taxi meter: real city tariffs, a glowing old-school meter when you turn the phone on its side, and receipts that print on a pocket thermal printer. Here is what building it taught me about Skip, background location and Bluetooth printers.",false,"md","\u002Fimages\u002Ffare_enough\u002Fcover.jpg",{},"\u002Fposts\u002Fcode_n_shit\u002Ffare_enough",{"title":5,"description":1733},"posts\u002Fcode_n_shit\u002Ffare_enough",[115,139,1742],"ios","fare-enough","fV7hR6IBGx_heC8ubmNJvWkc0taJPVc52OQVsj7PDfA",[1746],{"locale":1747,"path":1748,"title":1749},"ru","\u002Fru\u002Fposts\u002Fcode_n_shit\u002Ffare_enough","Я сделал таксометр для айфона. Просто так.",1790369235835]