<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>twocentstudios</title>
    <description>A coding blog covering iOS, Swift, and other programming topics.</description>
    <link>https://twocentstudios.com/blog/tags/app/index.html</link>
    <atom:link href="https://twocentstudios.com/blog/tags/app/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Sun, 01 Feb 2026 00:12:37 -0600</pubDate>
    <lastBuildDate>Sun, 01 Feb 2026 00:12:37 -0600</lastBuildDate>
    <generator>Jekyll v3.9.3</generator>
    
      <item>
        <title>Introducing Eki Bright 2.0</title>
        <description>&lt;p&gt;Eki Bright 2.0 is available today.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-app-icon.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Eki Bright app icon&quot; title=&quot;Eki Bright app icon&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Eki Bright app icon&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Eki Bright is the fastest way to access station timetables for the Tokyo metropolitan area.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://apps.apple.com/app/%E9%A7%85%E3%83%96%E3%83%A9%E3%82%A4%E3%83%88/id6504702463&quot;&gt;Download Eki Bright on the App Store&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The original design for the Eki Bright app evolved out of the initial use case of configuring Widgets, but steadily grew to support searching, nearby stations, DIY routing, and Live Activities.&lt;/p&gt;

&lt;p&gt;For Eki Bright 2.0, the goal was to optimize the app for how I’d &lt;em&gt;actually&lt;/em&gt; been using it and how its users had told me they use it.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-home-evolution.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Home screen evolution from v1.0 to v2.0&quot; title=&quot;Home screen evolution from v1.0 to v2.0&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Home screen evolution from v1.0 to v2.0&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The main focus is now nearby stations, which I found to be 95% of my usage over the year since its first release. In many cases, you’ll see the exact station timetable you want in less than a second after opening the app. Other nearby stations are a quick scroll at your resting thumb position, and order based on view history and distance from your current location.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-nearby-scroll.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Nearby stations screen with scrolling&quot; title=&quot;Nearby stations screen with scrolling&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Nearby stations screen with scrolling&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;From here, it’s super easy to start a new DIY route by tapping your departure train time, then tapping the route icon of your arrival station.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-diy-route-create.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Creating a DIY route by tapping departure time and arrival station&quot; title=&quot;Creating a DIY route by tapping departure time and arrival station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Creating a DIY route by tapping departure time and arrival station&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The route bar from v1.x has moved to the native tab bar accessory, which automatically expands and collapses.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-route-bar.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Route bar in the tab bar accessory&quot; title=&quot;Route bar in the tab bar accessory&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Route bar in the tab bar accessory&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;DIY routes now have a full screen view in Eki Bright 2.0. You can see the full route and transfers, and view all stops along your route.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-route-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Full screen route view with expanded stops list on the right&quot; title=&quot;Full screen route view with expanded stops list on the right&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Full screen route view with expanded stops list on the right&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It’s easy to compare adjacent departure times. For example, to check whether the next departing local train will actually reach your destination &lt;em&gt;after&lt;/em&gt; a later departing express train.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-route-compare-departures.gif&quot; width=&quot;&quot; height=&quot;500&quot; alt=&quot;Comparing local and express departure times, the express arrives 7 minutes earlier than the local that departs 3 minutes earlier&quot; title=&quot;Comparing local and express departure times, the express arrives 7 minutes earlier than the local that departs 3 minutes earlier&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Comparing local and express departure times, the express arrives 7 minutes earlier than the local that departs 3 minutes earlier&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Eki Bright remembers your most used route patterns and suggests them on the route screen based on your current location. After a few trips, you don’t need to manually create DIY routes anymore.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-suggested-routes.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Suggested routes based on location and history, departure times updating live&quot; title=&quot;Suggested routes based on location and history, departure times updating live&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Suggested routes based on location and history, departure times updating live&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The Live Activity now updates more reliably in the background throughout your trip.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-live-activity.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Live Activity on the lock screen&quot; title=&quot;Live Activity on the lock screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Live Activity on the lock screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My personal favorite new addition to Eki Bright 2.0 is the station relative direction arrow indicators on the station detail screens. This makes it much easier to orient yourself with the correct direction of a railway at a glance.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-station-direction-arrows.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Station detail with direction arrow indicators in relation to the center station&quot; title=&quot;Station detail with direction arrow indicators in relation to the center station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Station detail with direction arrow indicators in relation to the center station&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The station detail screen now shows train frequencies during different periods of the day.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-station-frequencies.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Train frequencies by time period&quot; title=&quot;Train frequencies by time period&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Train frequencies by time period&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The bookmarks and search screens have been separated out into their own tabs. I doubled down on native iOS 26 UI and UX (for better or worse).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-tab-bar.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;iOS 26 liquid glass tab bar with native accessory for the route view&quot; title=&quot;iOS 26 liquid glass tab bar with native accessory for the route view&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;iOS 26 liquid glass tab bar with native accessory for the route view&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In the search tab, you can now search railways.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-railway-search.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Railway search in the search tab&quot; title=&quot;Railway search in the search tab&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Railway search in the search tab&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Speaking of railways, the railway detail screen shows the full polyline of the railway.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-railway-detail.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Railway detail screen before and after with full polyline&quot; title=&quot;Railway detail screen before and after with full polyline&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Railway detail screen before and after with full polyline&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I think Eki Bright is the fastest and best way to get around Tokyo for everything but route planning. Local timetables give a serious edge to how fast you can find your next train.&lt;/p&gt;
</description>
        <pubDate>Sun, 18 Jan 2026 16:16:39 -0600</pubDate>
        <link>https://twocentstudios.com/2026/01/18/introducing-eki-bright-2.0/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2026/01/18/introducing-eki-bright-2.0/</guid>
        
        <category>app</category>
        
        <category>ekibright</category>
        
        
      </item>
    
      <item>
        <title>Shinkansen Live - Developing the App for iOS</title>
        <description>&lt;p&gt;In my &lt;a href=&quot;/2025/12/24/shinkansen-live-scan-your-ticket-get-a-live-activity/&quot;&gt;last post&lt;/a&gt; I introduced the motivation and feature set of &lt;a href=&quot;https://apps.apple.com/app/id6756808516&quot;&gt;Shinkansen Live&lt;/a&gt;, my latest iOS app. I encourage you to read that one first to learn about what the app does.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-app-icon.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;Shinkansen Live app icon&quot; title=&quot;Shinkansen Live app icon&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Shinkansen Live app icon&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In this post, I’ll discuss a few of the interesting development challenges I faced during its week of development from concept to App Store release.&lt;/p&gt;

&lt;h2 id=&quot;contents&quot;&gt;Contents&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;#overall-development-strategy&quot;&gt;Overall development strategy&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#ocr-and-parsing-the-ticket-image&quot;&gt;OCR and parsing the ticket image&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#live-activities&quot;&gt;Live Activities&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#animations&quot;&gt;Animations&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#alarmkit&quot;&gt;AlarmKit&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#localization&quot;&gt;Localization&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#dynamic-type&quot;&gt;Dynamic Type&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#visionkit-camera&quot;&gt;VisionKit Camera&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;overall-development-strategy&quot;&gt;Overall development strategy&lt;/h2&gt;

&lt;p&gt;I created an Xcode project myself with Xcode 26.1 (later switching to Xcode 26.2), then added the &lt;a href=&quot;https://github.com/pointfreeco/swift-composable-architecture&quot;&gt;TCA&lt;/a&gt; package.&lt;/p&gt;

&lt;p&gt;Then, I set off to work using Claude Code with Opus 4.5. I started by having it lay out the SwiftUI View and TCA Feature without any logic. Then I built out the rest of the infrastructure around getting the input image, doing OCR, parsing the output, and displaying the results. I’ll go through more of the history later on in the post.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-early-layouts-4panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Early view layouts during initial development&quot; title=&quot;Early view layouts during initial development&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Early view layouts during initial development&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;ocr-and-parsing-the-ticket-image&quot;&gt;OCR and parsing the ticket image&lt;/h2&gt;

&lt;p&gt;The most difficult part of getting this app to production was the ticket OCR &amp;amp; parsing system. This system went through the most churn over the week, partially due to expanding scope and partially due to my expanding understanding of the problem space.&lt;/p&gt;

&lt;p&gt;My initial thought during the prototyping stage was to target only the “ticket” screenshot from Eki-net you get after purchase. It looks something like this:&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-example-ekinet.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Example Eki-net ticket screenshot&quot; title=&quot;Example Eki-net ticket screenshot&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Example Eki-net ticket screenshot&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In theory, it’d be reasonable to limit the app’s input space to app screenshots from Eki-net (JR-East) and SmartEX (JR-central). Even including web browser screenshots wouldn’t be that much more burden on an OCR-based system. But later on in the project when I’d decided I was happy enough with the prototype that I wanted to target a production release on the App Store, I started thinking about how it would make marketing much harder to say “only works on screenshots” and not physical tickets.&lt;/p&gt;

&lt;h3 id=&quot;why-ocr-why-not-multi-modal-llms&quot;&gt;Why OCR? Why not multi-modal LLMs?&lt;/h3&gt;

&lt;p&gt;OCR via &lt;a href=&quot;https://developer.apple.com/documentation/visionkit&quot;&gt;VisionKit&lt;/a&gt; alongside manual parsing has a lot of upsides:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Free for user &amp;amp; developer&lt;/li&gt;
  &lt;li&gt;Fast&lt;/li&gt;
  &lt;li&gt;Multilingual&lt;/li&gt;
  &lt;li&gt;Privacy baked in&lt;/li&gt;
  &lt;li&gt;No network usage&lt;/li&gt;
  &lt;li&gt;Relatively mature: less risk of accuracy churn&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In theory, multimodal LLMs can handle OCR and understanding more variations of tickets layouts and bad lighting. In fact, while I was writing the parser, I used Opus 4.5 to read the test ticket images in order to create the ground truth test expectation data.&lt;/p&gt;

&lt;p&gt;My issue with prototyping with LLMs further was that they had essentially the opposite pros and cons as the VisionKit system:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Unknown, but some ongoing cost (meaning I’d need to come up with a monetization strategy before release)&lt;/li&gt;
  &lt;li&gt;Unknown which level of model would correctly balance accuracy, cost, and speed over the short term.&lt;/li&gt;
  &lt;li&gt;Requires network access&lt;/li&gt;
  &lt;li&gt;Requires sending photo data off device (not hugely private, but still)&lt;/li&gt;
  &lt;li&gt;Different outputs for the same input&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I didn’t explore directly was using the Apple Foundation model at the &lt;em&gt;parsing&lt;/em&gt; layer. The current Apple Foundation model in iOS 26 has no image input API, but I could perhaps prompt it to take the raw output of the VisionKit model and try to make sense of it. My instinct is that this would be a waste of time, but still worth keeping on the table.&lt;/p&gt;

&lt;h3 id=&quot;overall-strategy&quot;&gt;Overall strategy&lt;/h3&gt;

&lt;p&gt;While working on this, I honestly wasn’t thinking strictly in terms of prototype &amp;amp; production. From the first spark of idea I had an understanding of what the overall UX flow of the app would be. It was mostly getting to an answer of “is this feasible to productionize in a couple days?” while still being flexible on the scope of what &lt;em&gt;production-ready&lt;/em&gt; meant.&lt;/p&gt;

&lt;p&gt;That meant that I started by adding my ticket screenshot to the project, giving Claude my overall strategy, and having Claude create the OCR &amp;amp; parser system that output results directly into the UI.&lt;/p&gt;

&lt;h3 id=&quot;ocr-prototype&quot;&gt;OCR prototype&lt;/h3&gt;

&lt;p&gt;The first prototype parser supported just the two screenshots I had of Eki-net tickets (one from the morning of my trip; another from a similar trip a few months ago).&lt;/p&gt;

&lt;p&gt;The implementation set up a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; in Japanese language mode, read out the highest ranking results into several lines of text (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt;), then fed that to Claude’s homegrown parser that pulled the ticket attributes out of that glob of text mostly using regex.&lt;/p&gt;

&lt;p&gt;Since the input was from a perfectly legible screenshot, there was no issues with the VisionKit part nor the parser.&lt;/p&gt;

&lt;h3 id=&quot;ocr-for-real-shinkansen-ticket-images&quot;&gt;OCR for real Shinkansen ticket images&lt;/h3&gt;

&lt;p&gt;As soon as I tested the system on a real Shinkansen ticket in a photo, the system fell apart.&lt;/p&gt;

&lt;p&gt;I searched through my personal Photo library for as many Shinkansen tickets as I could find. I googled for more. I started with 4 images (and later in development I ended up with about 10 images).&lt;/p&gt;

&lt;p&gt;At first I was simply trying to naively patch out the parser with Claude. To do this, I set up a unit test system where I’d do the VisionKit request for each image once and write the resulting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt; data structure to disk. Then each unit test would read that data in, run the parser, and compare the expected ticket structure to the test result.&lt;/p&gt;

&lt;p&gt;Unintuitively, the standard Swift Testing unit test setup was actually the less efficient way to iterate on this. Claude was having a lot of trouble reading the detailed test failure information after it ran xcodebuild in the command line. Each build &amp;amp; run &amp;amp; test iteration needed to boot up a fresh simulator, install the app, run the test, then tear down the simulator.&lt;/p&gt;

&lt;p&gt;Instead, a built a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@main&lt;/code&gt; App-based test harness that:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;temporarily disabled the real UI.&lt;/li&gt;
  &lt;li&gt;ran the test code on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onAppear&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;printed the test results via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print&lt;/code&gt; statements.&lt;/li&gt;
  &lt;li&gt;called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exit(0)&lt;/code&gt; when finished.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For quickly prototyping, iterating, and understanding the scope of the problem space, this solved all the issues with the Swift Testing setup. The same booted simulator was reused on each run. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DerivedData&lt;/code&gt; and build artifacts were reused, so builds were fast. Claude had no trouble reading print statements from the console output.&lt;/p&gt;

&lt;p&gt;I let Claude run in its own loop for a while to see what it could and couldn’t improve with the parser based on the limitations of our system.&lt;/p&gt;

&lt;p&gt;Claude found several underlying limitations with the VisionKit setup that were unsolvable at the parser level.&lt;/p&gt;

&lt;p&gt;For example, concatenating all the text recognition objects into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt; was done somewhat naively by comparing y-coordinates. If the y-coordinates of objects were within a certain range, they were assumed to be on the same line. When the ticket was tilted, this strategy was interleaving text.&lt;/p&gt;

&lt;p&gt;Additionally, some numbers and letters were just flat out being interpreted incorrectly. A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;37&lt;/code&gt; was read as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;30&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CAR&lt;/code&gt; was read as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CDR&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Off running on its own, Claude was trying to special case as much of these failure cases it could to get the tests passing.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-test-ticket-04.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Example test ticket image that produced the Japanese-only VisionKit parsing output shown below&quot; title=&quot;Example test ticket image that produced the Japanese-only VisionKit parsing output shown below&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Example test ticket image that produced the Japanese-only VisionKit parsing output shown below&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;CC制
東
3月18日（
はくたから5り考
¥3,380
新幹線特急券
京
→
8:41発）
軽井
（9:4着）
7号車
3番B席
R001
2025.-3.18東京北乗FN7（2－）
50159-01
沢
0
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Comparing some of the fields, you can see the OCR output taken naively is not great:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The departure time is there but the arrival time is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;9:4&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;9:43&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The arrival station’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;軽井&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;沢&lt;/code&gt; are split up and should be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;軽井沢&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The departure station’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;東&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;京&lt;/code&gt; are split up and should be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;東京&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;The train name and number are a mess: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;はくたから5り考&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;はくたか 555号&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;using-spatial-data&quot;&gt;Using spatial data&lt;/h3&gt;

&lt;p&gt;The OCR part of the system was trying to abstract away the spatial parts from the parser. Looking at the raw data, my intuition was that the spacial data could be useful within the parsing layer. Instead of passing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[String]&lt;/code&gt; between layers, I was now passing:&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Each field parser within the parsing system could decide for itself how to use the spacial data. This is especially useful considering how important &lt;em&gt;anchor values&lt;/em&gt; are. For example “発” (indicating a departure time) and “→” (indicating the station to the left is the departure station).&lt;/p&gt;

&lt;p&gt;This improved things a bit for 5 ticket images, but after adding another 5 and going all in on English ticket support, there were plenty more edge cases to consider.&lt;/p&gt;

&lt;h3 id=&quot;transforms&quot;&gt;Transforms&lt;/h3&gt;

&lt;p&gt;Only after seeing some wonky positioning of the text boxes in my loading animation (see below), I realized that I wasn’t accounting for the image transform properly when converting the input &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UIImage&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CGImage&lt;/code&gt; as input to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGImagePropertyOrientation&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;uiOrientation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;UIImage&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Orientation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;switch&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;uiOrientation&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;up&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;up&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;upMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upMirrored&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;down&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;down&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;downMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;downMirrored&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;left&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;left&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;leftMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;leftMirrored&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;right&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;right&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;rightMirrored&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rightMirrored&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;@unknown&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;up&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;levenshtein-distance&quot;&gt;Levenshtein distance&lt;/h3&gt;

&lt;p&gt;Like our previous example of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CDR&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CAR&lt;/code&gt;, VisionKit was reading strings like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TOKIO&lt;/code&gt; instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TOKYO&lt;/code&gt;. For these parts, I figured calculating &lt;a href=&quot;https://en.wikipedia.org/wiki/Levenshtein_distance&quot;&gt;Levenshtein distance&lt;/a&gt; from known strings was the right strategy. The Shinkansen system is large but not so large that I couldn’t ingest the station name values and the other known strings like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;JAN&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FEB&lt;/code&gt;, etc.&lt;/p&gt;

&lt;p&gt;One strategy I haven’t tested yet is using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest.customWords&lt;/code&gt; with the full dictionary of station names, etc. to see if that eliminates the need for using Levenshtein distance at all.&lt;/p&gt;

&lt;h3 id=&quot;english-and-japanese-mode-ocr&quot;&gt;English and Japanese mode OCR&lt;/h3&gt;

&lt;p&gt;For some background, tickets can be printed in an “English” variant that includes a mix of English and Japanese text. If you buy from a ticket vending machine and complete the purchase in English mode, it’ll print an English ticket. Similarly, if you buy from a human ticket vendor at the counter, they will print your ticket in English variant if you speak English to them.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-example-english-ticket.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Example of an English variant Shinkansen ticket&quot; title=&quot;Example of an English variant Shinkansen ticket&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Example of an English variant Shinkansen ticket&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;At the VisionKit layer, I was first using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; in Japanese-mode only.&lt;/p&gt;

&lt;p&gt;I tried expanding a single instance to include both English and Japanese text. But checking the raw results, a dual language setup severely impaired its abilities.&lt;/p&gt;

&lt;p&gt;For a while, I had a dual parsing system that would check for a few English strings, and if any were found, it would assume the ticket was an “English ticket” and run a separate English &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; and return those results. This didn’t work well for a few reasons:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The Japanese &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt; was surprisingly bad at reading numbers compared to the English one.&lt;/li&gt;
  &lt;li&gt;Deciding at the VisionKit layer which text results should come from the English request didn’t make a lot of sense conceptually if I wanted to keep the majority of the logic in the parser layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Therefore, I decided to run both the English and Japanese &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNRecognizeTextRequest&lt;/code&gt;s on every input and provide all the results to the parser.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;BilingualOCRResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Codable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;jp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;en&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I was also previously discarding the confidence score (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.0...1.0&lt;/code&gt;) that VisionKit provides with each observation. This score was actually different in the parallel observations for English and Japanese in some cases, especially number recognition. I added the confidence score to the output so the parser could use it.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TextObservation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Equatable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Sendable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Codable&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Double&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;confidence&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Float&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;unparseable-fields-fallback-ux&quot;&gt;Unparseable fields fallback UX&lt;/h3&gt;

&lt;p&gt;At this point in the development of the parser, I was pretty certain reading photos of tickets was never going to be reach 100% accuracy for every field.&lt;/p&gt;

&lt;p&gt;I took a break from working on the parser to implement editing for every field in the ticket UI. This meant that users could manually recover from parsing errors and omissions.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-editing-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Editing screen with editable ticket fields&quot; title=&quot;Editing screen with editable ticket fields&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Editing screen with editable ticket fields&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Not needing to reach 100% accuracy in the parser while still ensuring the user gets value out of the system as a whole opened up a more reasonable strategy for the parser.&lt;/p&gt;

&lt;h3 id=&quot;defense-in-depth-parsing-system&quot;&gt;Defense-in-depth parsing system&lt;/h3&gt;

&lt;p&gt;After tweaking more and more of the VisionKit layer, I had to regenerate the VisionKit output test data for each test ticket image, and then essentially rewrite the parser layer.&lt;/p&gt;

&lt;p&gt;This time, the strategy was to:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Pass in the full English and Japanese results including relative x and y coordinates to each field.&lt;/li&gt;
  &lt;li&gt;Create a sub-parser dedicated to each field of the ticket that needed to be parsed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Within each sub-parser, my strategy was to start with the best case scenario of input data quality, then step-by-step keep loosening the guidelines to account for more unideal cases that had come up in the test data, then finally falling back to returning &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nil&lt;/code&gt; for that field.&lt;/p&gt;

&lt;h3 id=&quot;ocr-testing-system&quot;&gt;OCR testing system&lt;/h3&gt;

&lt;p&gt;Claude iterated on the field parser implementations for an hour or two, one at a time, checking the test output to ensure there were no regressions along the way.&lt;/p&gt;

&lt;p&gt;At a certain point all the tests were passing and as much as I wanted to keep finding test data and tweaking the parser, I knew I had to move on.&lt;/p&gt;

&lt;h2 id=&quot;live-activities&quot;&gt;Live Activities&lt;/h2&gt;

&lt;p&gt;During development I had to keep reminding myself that the whole point of this endeavor was to have a slick (read: &lt;em&gt;useful&lt;/em&gt;) Live Activity.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-live-activity-3panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; title=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Live Activity in Dynamic Island compact, expanded, and lock screen views&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My design process was quick and to-the-point:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;list out all the Live Activity contexts: lock screen, dynamic island compact leading, compact trailing, minimal, and expanded.&lt;/li&gt;
  &lt;li&gt;consider all the ticket info I had available from the parser output.&lt;/li&gt;
  &lt;li&gt;consider all of the above for the “before” and “during” trip phases (if I knew I had more accurate control over Live Activity update timing, I might have divided these phases up even further).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In lock screen and expanded contexts, you can &lt;em&gt;mostly&lt;/em&gt; display everything you want. The challenge is in aesthetics and visual hierarchy like in any design.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-lockscreen-live-activity.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Lock screen Live Activity showing trip details&quot; title=&quot;Lock screen Live Activity showing trip details&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Lock screen Live Activity showing trip details&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;When dealing with the compact and minimal contexts, you really do only have the equivalent of about 6 very small characters to work with, and 2 lines if you want to push your luck. If you try to fill the entire available space of the Dynamic Island on either side, you’ll lose the system clock which is no go for my use case.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-compact-live-activity.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Compact Live Activity in Dynamic Island&quot; title=&quot;Compact Live Activity in Dynamic Island&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Compact Live Activity in Dynamic Island&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;When I thought about it, the most important of all the contexts was the compact leading and trailing in the &lt;em&gt;before&lt;/em&gt; trip phase. This is when the information is most needed at a glance.&lt;/p&gt;

&lt;p&gt;I stacked in departure time and train number in the compact leading. This information is used to decide when to go to the platform and which platform to go to.&lt;/p&gt;

&lt;p&gt;I stacked the car and seat number in the compact trailing. These are used to decide where to line up on the platform and of course where you’ll sit.&lt;/p&gt;

&lt;h3 id=&quot;view-guidelines-and-tips&quot;&gt;View guidelines and tips&lt;/h3&gt;

&lt;p&gt;Designing for Live Activities is painful. There are significantly more constraints to the SwiftUI View system than in normal app contexts. Most are undocumented. Some quirks can be teased out in the SwiftUI Preview if you’re lucky. Others only appear on the simulator or a real device.&lt;/p&gt;

&lt;p&gt;I have a couple guidelines and tips I follow for Live Activities.&lt;/p&gt;

&lt;h4 id=&quot;use-non-semantic-font-sizes-for-compact-and-minimal&quot;&gt;Use non-semantic font sizes for compact and minimal&lt;/h4&gt;

&lt;p&gt;The system ignores Dynamic Type settings in the compact and minimal Dynamic Island contexts. Using point sizes directly gives more flexibility while designing in the very limited space.&lt;/p&gt;

&lt;p&gt;In the lock screen and expanded contexts, there’s limited Dynamic Type support (4-levels total), so it’s still worth using semantic fonts as usual (e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.headline&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.title3&lt;/code&gt;).&lt;/p&gt;

&lt;h4 id=&quot;for-dynamically-updating-times-prepare-to-spend-a-lot-of-time-in-trial-and-error&quot;&gt;For dynamically updating times, prepare to spend a lot of time in trial and error&lt;/h4&gt;

&lt;p&gt;Maybe someday I’ll write a full explainer post on which countdown-style &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Text&lt;/code&gt; fields are supported. In short, if you want dynamically updated fields in any part of your Live Activity, your formatting options are limited and underdocumented.&lt;/p&gt;

&lt;p&gt;A couple configurations I used:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;// `44 min, 23 sec` or `1 hr, 52 min`
Text(attributes.departureTime, style: .relative)

// Centers `Departing in N min, M, sec` due to `.relative`&apos;s implicit `maxWidth: .infinity`
HStack(spacing: 4) {
    Text(String(localized: &quot;widget.departing-in&quot;, comment: &quot;Footer label shown before departure&quot;))
        .frame(maxWidth: .infinity, alignment: .trailing)
    Text(attributes.departureTime, style: .relative)
}

// Linear progress view with no label
ProgressView(timerInterval: attributes.departureTime ... attributes.arrivalTime, countsDown: false)
    .progressViewStyle(.linear)
    .labelsHidden()
   
// `60:00`
Text(
     timerInterval: (Date.now)...(Date(timeIntervalSinceNow: 60*60)),
     pauseTime: Date(timeIntervalSinceNow: 60*60),
     countsDown: true,
     showsHours: false
)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;As noted above, any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Text&lt;/code&gt; using: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Text(Date(), style: .relative)&lt;/code&gt; will expand to fill its full width.&lt;/p&gt;

&lt;p&gt;It’s frustrating just thinking about this again. I basically just banged my head against the wall until I landed on a design I felt embarrassed but comfortable shipping.&lt;/p&gt;

&lt;h4 id=&quot;clamped-width-custom-layout&quot;&gt;Clamped width custom Layout&lt;/h4&gt;

&lt;p&gt;I use this custom &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Layout&lt;/code&gt; judiciously in the Dynamic Island.&lt;/p&gt;

&lt;p&gt;It makes the underlying view’s frame collapse to fit its ideal width, but clamped to a maximum value.&lt;/p&gt;

&lt;p&gt;I want the compact or minimal context to be as narrow as possible. I want short input text to result in a very narrow Dynamic Island layout. I want longer input not to expand beyond a certain width; and even more, I want to use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;minimumScaleFactor&lt;/code&gt; modifier to further shrink the text size once that maximum width is reached.&lt;/p&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SingleViewClampedWidthLayout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Layout&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGFloat&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;sizeThatFits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ProposedViewSize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;inout&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGSize&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subview&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;first&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;zero&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;idealWidth&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subview&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sizeThatFits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;width&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;idealWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subview&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;sizeThatFits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;placeSubviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;bounds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGRect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ProposedViewSize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Subviews&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;inout&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;guard&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;subview&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subviews&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;first&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;subview&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;place&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bounds&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;origin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;proposal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bounds&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ClampedWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ViewModifier&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGFloat&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;SingleViewClampedWidthLayout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;@ViewBuilder&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;clamped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CGFloat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;View&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;modifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;ClampedWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;maxWidth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h3 id=&quot;updating-in-background&quot;&gt;Updating in background&lt;/h3&gt;

&lt;p&gt;Live Activities can usually only be updated via remote Push Notification. But if your app gets a chance to wake up and run in the background, it can also issue updates to the Live Activity.&lt;/p&gt;

&lt;p&gt;One of the few reliable ways to have your app woken up in the background regularly is to use significant location updates from Core Location. In order to have your app get background time you need to:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Be approved by the user for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Always&lt;/code&gt; Location Services permission.&lt;/li&gt;
  &lt;li&gt;Be approved by the user for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WhenInUse&lt;/code&gt; Location Services permission AND one of the following
    &lt;ul&gt;
      &lt;li&gt;Have an active Live Activity OR&lt;/li&gt;
      &lt;li&gt;Start a CLBackgroundActivitySession (that essentially creates a default Live Activity)&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the case of Shinkansen Live, I have two phases for the Live Activity:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;before the train departs&lt;/li&gt;
  &lt;li&gt;after the train departs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The time of that change over is pretty reliably scheduled. But Live Activities have no update schedule like Widgets do (for some reason).&lt;/p&gt;

&lt;p&gt;To update the Live Activity after the train departs I could:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Have the user tap a button in the lock screen or expanded Live Activity that triggers an intent to wake up the app in the background and update the Live Activity.&lt;/li&gt;
  &lt;li&gt;Hope the user opens the app on their own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both of these options are unideal, so instead I ask for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WhenInUse&lt;/code&gt; Location Services permission and start a monitoring for significant locations. One of these will be fired not long after the train departs (within about 1km or 5 minutes). That trigger will open the app in the background, update the Live Activity based on the current time, then go back to sleep.&lt;/p&gt;

&lt;h3 id=&quot;persisting-the-trip&quot;&gt;Persisting the trip&lt;/h3&gt;

&lt;p&gt;There’s an edge case I wanted to handle with the Live Activity lifetime.&lt;/p&gt;

&lt;p&gt;If the system kills the app in the middle of a trip, the Live Activity in theory should continue uninterrupted since it’s an App Extension. But in the case of Shinkansen Live, I’m expecting to update the Live Activity while the app is backgrounded. This means there’s a potential flow where:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The app is in the background with the Live Activity running.&lt;/li&gt;
  &lt;li&gt;The app is killed by the system.&lt;/li&gt;
  &lt;li&gt;The Live Activity continues to run.&lt;/li&gt;
  &lt;li&gt;The system cold launches the app in the background.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this point, I could decide to query the Live Activities framework to see if there’s a Live Activity running and if so, restore the ticket model layer and UI. However, I prefer not to treat the Live Activity as the source of truth for the model layer.&lt;/p&gt;

&lt;p&gt;I added support with the &lt;a href=&quot;https://github.com/pointfreeco/swift-sharing&quot;&gt;Sharing&lt;/a&gt; library to persist the ticket model automatically on changes. On the above flow, I use the persisted ticket model to restore the UI and Live Activity and AlarmKit state, ensuring the ticket data is still valid.&lt;/p&gt;

&lt;h3 id=&quot;handling-dismissal&quot;&gt;Handling dismissal&lt;/h3&gt;

&lt;p&gt;One final bit of UX that’s not mission critical but is very user friendly is to respond to user-initiated Live Activity dismissals from the lock screen. If the user swipes from right to left on your Live Activity, the system dismisses it. When your app next runs, it will receive an update from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Activity.activityStateUpdates&lt;/code&gt; stream (if you’re monitoring it).&lt;/p&gt;

&lt;p&gt;If the app detects a user-initiated dismissal, I consider that their trip has ended, clear the ticket, and go back to the app home screen. There’s an argument that it’d be safer to simply toggle the Live Activity, but since my app’s only purpose is to show a Live Activity, I don’t think it makes sense to build in more complexity to keep multiple states.&lt;/p&gt;

&lt;h2 id=&quot;animations&quot;&gt;Animations&lt;/h2&gt;

&lt;p&gt;The focused nature of this app allowed me a bit more breathing room to experiment with custom screen transitions and multi-stage animations.&lt;/p&gt;

&lt;h3 id=&quot;root-level-transitions&quot;&gt;Root level transitions&lt;/h3&gt;

&lt;p&gt;At the bare minimum, I usually try to use a default opacity transition for root level views when they aren’t covered by system transitions like a navigation push or sheet presentation.&lt;/p&gt;

&lt;p&gt;For Shinkansen Live, I added a little bit of extra scale effect to the usual opacity transition of the initial screen, both on cold launch and when returning from the trip screen.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-initial-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-initial-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;p&gt;For the trip screen, I first animated the card down from the top with some scale and opacity, then fade in the other sections with some scale.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-trip-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-trip-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;scanning-animation&quot;&gt;Scanning animation&lt;/h3&gt;

&lt;p&gt;The most fun was doing the scanning animation. Once the selected image is downloaded and displayable, I animate it in with a bit of 3D effect. Then I use the coordinate results of the text observations to animate those boxes onto the image.&lt;/p&gt;

&lt;p&gt;This animation serves a few purposes in my opinion:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Visually expresses to the user what the app is actually doing.&lt;/li&gt;
  &lt;li&gt;Buys time for the parser to do its job.&lt;/li&gt;
  &lt;li&gt;Feels fun and playful in a way that is motivating for users to want to go through the trouble of submitting their ticket image.&lt;/li&gt;
&lt;/ul&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-scanning-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-scanning-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;ticket-image-modal-animation&quot;&gt;Ticket image modal animation&lt;/h3&gt;

&lt;p&gt;My final bit of (self) user testing made me realize that even though I’d built in a way to update ticket values that were missing or erroneously parsed, I had no in-app UI for actually doing the field checking. Depending on their input source, the user would have to hold up their physical ticket next to the app’s virtual ticket to double check the fields. Or if they’d used a screenshot, they’d have to flip back and forth between Photos app.&lt;/p&gt;

&lt;p&gt;As my last big task, I added support for showing the original image inline with the ticket in a modal overlay.&lt;/p&gt;

&lt;p&gt;This setup uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;matchedGeometryEffect&lt;/code&gt; and was a nightmare to work through. In the end it’s not perfect, but the speed conceals some of the jankiness. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;matchedGeometryEffect&lt;/code&gt; has a lot of undocumented incompatibilities with other modifiers, so it was just hours upon hours of reordering modifiers and building and running to check what had changed. I came out of the experience with little new demonstrably true observations I can share here, unfortunately.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-image-transition.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-image-transition-slowmo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;card-dragging&quot;&gt;Card dragging&lt;/h3&gt;

&lt;p&gt;Whenever there’s a card-looking UI on screen, I want it to be interactable even if there’s no real gesture that makes sense.&lt;/p&gt;

&lt;p&gt;I created a custom drag gesture with rubberbanding that allows the user to drag the ticket a little bit in any direction. When released, it snaps back with a custom haptic that mirrors the visual.&lt;/p&gt;

&lt;video controls=&quot;&quot; loop=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-card-drag.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h2 id=&quot;alarmkit&quot;&gt;AlarmKit&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/alarmkit&quot;&gt;AlarmKit&lt;/a&gt; is new in iOS 26 and I thought it might be a good fit for Shinkansen Live’s use case.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-alarm-2panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Alarm setting in trip screen and full screen alarm notification&quot; title=&quot;Alarm setting in trip screen and full screen alarm notification&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Alarm setting in trip screen and full screen alarm notification&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The integration was mostly straightforward, but it added another layer of complexity to the reducer implementation to ensure that it was added, changed, and removed for all the relevant cases:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Arrival time exists or doesn’t exist.&lt;/li&gt;
  &lt;li&gt;Arrival time is updated manually by the user.&lt;/li&gt;
  &lt;li&gt;System time is too close to arrival time to set an alarm.&lt;/li&gt;
  &lt;li&gt;Journey is ended by the user before arrival time.&lt;/li&gt;
  &lt;li&gt;Live Activity is dismissed from the lock screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And more.&lt;/p&gt;

&lt;h3 id=&quot;permission-dialog&quot;&gt;Permission dialog&lt;/h3&gt;

&lt;p&gt;A last minute annoyance with AlarmKit was testing localization: I &lt;a href=&quot;https://hachyderm.io/@twocentstudios/115740319076548675&quot;&gt;found a bug&lt;/a&gt; where the localized text for the AlarmKit permissions dialog was not being used on iOS 26.0. But the bug was fixed for iOS 26.1. And no, the bug nor the fix were mentioned in any official SDK release notes.&lt;/p&gt;

&lt;h2 id=&quot;localization&quot;&gt;Localization&lt;/h2&gt;

&lt;p&gt;One of my favorite usages for coding agents is doing Localization setup. Note I’m specifically &lt;em&gt;not&lt;/em&gt; talking about LLMs doing the actual translation, but instead:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;doing the initial conversion from inline strings to string keys e.g, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;loaded.end-journey-button&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;adding localizer comments to each string key e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Button to end the current journey&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;maintaining default values for when string interpolations are required e.g.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-swift highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;Button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;localized&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;loaded.arrival-alert.status.minutes-before&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;defaultValue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mins&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; min before&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Status showing minutes before arrival (for menu items)&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;...&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The initial conversion uses a custom markdown document with some basic rules. It takes about an hour for the first run and then a few more passes with a human in the loop to ensure the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcstrings&lt;/code&gt; file is clean.&lt;/p&gt;

&lt;h2 id=&quot;dynamic-type&quot;&gt;Dynamic Type&lt;/h2&gt;

&lt;p&gt;The app still lays out pretty well with most levels of Dynamic Type. I only use semantic font qualifiers. All content is in a scroll view that’s usually fixed.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-dynamic-type-landing.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Landing screen with various Dynamic Type sizes&quot; title=&quot;Landing screen with various Dynamic Type sizes&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Landing screen with various Dynamic Type sizes&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-dynamic-type-trip.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Trip screen with various Dynamic Type sizes&quot; title=&quot;Trip screen with various Dynamic Type sizes&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Trip screen with various Dynamic Type sizes&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;visionkit-camera&quot;&gt;VisionKit Camera&lt;/h2&gt;

&lt;p&gt;I’m using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VNDocumentCameraViewController&lt;/code&gt; as the integrated camera view for scanning. The UX is a little weird because there’s no way to limit the input (output?) to one photo. The result is the user can take a bunch of photos of their ticket before they tap “Done” and the app will only read the first.&lt;/p&gt;

&lt;h2 id=&quot;project-stats&quot;&gt;Project stats&lt;/h2&gt;

&lt;h3 id=&quot;by-the-numbers&quot;&gt;By the numbers&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Development time: &lt;strong&gt;~6 days&lt;/strong&gt; including App Store materials&lt;/li&gt;
  &lt;li&gt;Lines of Swift (excluding tests, static data, etc.): &lt;strong&gt;8,388&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;daily-devlog&quot;&gt;Daily Devlog&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Dec 16, 2025 — Initial layout &amp;amp; OCR foundation&lt;/li&gt;
  &lt;li&gt;Dec 17, 2025 — Live Activities, AlarmKit, failure states&lt;/li&gt;
  &lt;li&gt;Dec 18, 2025 — Bilingual OCR, loading animation, app icon, localization&lt;/li&gt;
  &lt;li&gt;Dec 19, 2025 - Parser rewrite, settings view&lt;/li&gt;
  &lt;li&gt;Dec 20, 2025 — Polish, persistence, supported formats view, App Store prep&lt;/li&gt;
  &lt;li&gt;Dec 23, 2025 — Ticket image modal, App Store materials, v1.0 Release&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;Part of the appeal of this idea is that the scope could only creep so much. I honestly didn’t leave much on the TODO list for a version 1.1 besides endless optimization potential for the parser.&lt;/p&gt;

&lt;p&gt;As usual this post was brain-dump style. If there’s any part you connected with and would like me to explore further, feel free to give me a shout.&lt;/p&gt;
</description>
        <pubDate>Thu, 25 Dec 2025 07:41:42 -0600</pubDate>
        <link>https://twocentstudios.com/2025/12/25/shinkansen-live-developing-the-app-for-ios/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/12/25/shinkansen-live-developing-the-app-for-ios/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>app</category>
        
        <category>shinkansenlive</category>
        
        
      </item>
    
      <item>
        <title>Shinkansen Live: Scan Your Ticket, Get a Live Activity</title>
        <description>&lt;p&gt;Today I’m releasing my latest iOS app: Shinkansen Live or 新幹線ライブ in Japanese.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://apps.apple.com/app/id6756808516&quot;&gt;Shinkansen Live on the App Store&lt;/a&gt;&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-app-icon.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;Shinkansen Live app icon&quot; title=&quot;Shinkansen Live app icon&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Shinkansen Live app icon&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The concept is simple: you scan your Shinkansen ticket or receipt and you can see the details of your trip in a Live Activity on your lock screen and Dynamic Island.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-scan-flow-3panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Scan flow in 3 panels: scanning, ticket, lock screen&quot; title=&quot;Scan flow in 3 panels: scanning, ticket, lock screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Scan flow in 3 panels: scanning, ticket, lock screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Here’s a quick screen capture of the main flow:&lt;/p&gt;

&lt;video poster=&quot;/images/shinkansen-v1-app-preview-poster.png&quot; controls=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-app-preview.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h2 id=&quot;motivation&quot;&gt;Motivation&lt;/h2&gt;

&lt;p&gt;I took a quick Shinkansen trip from Omiya (north Tokyo) to Karuizawa (Nagano) last week to do some co-working with my friends Jens and David. I had pre-purchased a reserved seat with the &lt;a href=&quot;https://www.eki-net.com/en/jreast-train-reservation/Top/Index&quot;&gt;Eki-net&lt;/a&gt;, JR-East’s Shinkansen app (on iOS, it’s a web app wrapper). Similar to when I have a physical ticket (but somehow worse?) I found myself opening the app repeatedly to check my ticket’s listed attributes for:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;departure time: in the 30 minutes or so lead up, before I’d entered the gates.&lt;/li&gt;
  &lt;li&gt;train number: to cross reference and check the platform I should leave from.&lt;/li&gt;
  &lt;li&gt;car number: when it was time to ascend to the platform and look for where on the platform I should line up.&lt;/li&gt;
  &lt;li&gt;seat number: when the train pulled up and I was boarding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both of my train apps &lt;a href=&quot;/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright&lt;/a&gt; and &lt;a href=&quot;/2025/06/03/eki-live-announcement/&quot;&gt;Eki Live&lt;/a&gt; have Live Activities support that I use frequently. I only ride the Shinkansen a few times a year, but while riding up to Karuizawa that day, I wondered, &lt;strong&gt;couldn’t I just OCR the Shinkansen ticket info from screenshot and stuff it into a Live Activity?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And so as soon as I arrived at Sawamura Roastery in Karuizawa, I got to work on prototyping a new app. My goal was to have a prototype by the ride home. With some extra polish it ended up taking a few more days of work.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-sawamura-fireplace.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Co-working vibes at Sawamura Roastery in Karuizawa&quot; title=&quot;Co-working vibes at Sawamura Roastery in Karuizawa&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Co-working vibes at Sawamura Roastery in Karuizawa&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;features&quot;&gt;Features&lt;/h2&gt;

&lt;p&gt;The structure of the app is essentially a landing screen, a processing screen, and a trip-in-progress screen. The Live Activity requires its own multiple states and layouts of UI. For polish, I needed an error screen, an about screen, and a screen explaining what ticket formats are accepted.&lt;/p&gt;

&lt;h3 id=&quot;supported-input-formats&quot;&gt;Supported input formats&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-landing-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Landing screen showing input options&quot; title=&quot;Landing screen showing input options&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Landing screen showing input options&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My initial scope was just handling screenshots from Eki-net (JR-East’s app) and SmartEX (JR-Central’s app), and in retrospect this probably would have better line to draw in the sand for version 1. However, I added support for scanning physical tickets too since the app seemed like it would be &lt;em&gt;too&lt;/em&gt; specialized without physical tickets, probably the majority use-case.&lt;/p&gt;

&lt;p&gt;And so, you can scan your physical ticket with the camera, choose a screenshot from the Photo Library, paste an image from the system pasteboard, or create an empty ticket if you want.&lt;/p&gt;

&lt;h3 id=&quot;ocr-and-parsing&quot;&gt;OCR and parsing&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-scanning.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Scanning a ticket with OCR in progress&quot; title=&quot;Scanning a ticket with OCR in progress&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Scanning a ticket with OCR in progress&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;As of version 1.0, the app uses on device VisionKit to recognize text in the image and custom algorithm to do error recovery and parse out the relevant attributes from the ticket. I’ll discuss the development aspects of this decision in a future post, but for now, I’ll say that the merits of using OCR over multi-modal LLMs are that OCR is very fast, maintains privacy, and is accurate enough for a V1.&lt;/p&gt;

&lt;h3 id=&quot;trip-in-progress&quot;&gt;Trip in-progress&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-trip-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Trip in-progress screen showing ticket details&quot; title=&quot;Trip in-progress screen showing ticket details&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Trip in-progress screen showing ticket details&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Once the ticket is scanned and parsed, you land on the trip screen for the remainder of your journey.&lt;/p&gt;

&lt;p&gt;I recreated a facsimile of the legendary Shinkansen ticket. While doing research into ticket formats, it was surprising to see how &lt;em&gt;different&lt;/em&gt; the information layouts are depending on where and by what means they are purchased, but the aesthetic is generally the same.&lt;/p&gt;

&lt;p&gt;For the case of physical tickets, parsing is imperfect, so I wanted to ensure users could recover from minor errors like a missing time or train number. Therefore, all fields are user editable by tapping.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-editing-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Editing screen with editable ticket fields&quot; title=&quot;Editing screen with editable ticket fields&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Editing screen with editable ticket fields&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I also include the input image that a user can reference in an expanded view. This makes it easier to double check values and fix mistakes.&lt;/p&gt;

&lt;video poster=&quot;/images/shinkansen-v1-expand-photo-poster.png&quot; controls=&quot;&quot; style=&quot;max-height: 400px;&quot;&gt;
  &lt;source src=&quot;/images/shinkansen-v1-expand-photo.mp4&quot; type=&quot;video/mp4&quot; /&gt;
&lt;/video&gt;

&lt;h3 id=&quot;live-activity&quot;&gt;Live Activity&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-live-activity-3panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; title=&quot;Live Activity in Dynamic Island compact, expanded, and lock screen views&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Live Activity in Dynamic Island compact, expanded, and lock screen views&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Finally, the whole point of all this is to have a functional Live Activity. The Live Activity has a &lt;em&gt;before&lt;/em&gt; and &lt;em&gt;during&lt;/em&gt; layout. Before departure we show the departure time, train number, car number, and seat number (for reserved seats). During the trip, we show the arrival station and time.&lt;/p&gt;

&lt;p&gt;Due to a technical limitation with Live Activities, I use Location Services to monitor for significant location changes in the background, and use that to wake up the app and update the Live Activity when the departure time has passed. On the technical side, this means I don’t need to run a push notification server or do any other networking from the app.&lt;/p&gt;

&lt;h3 id=&quot;arrival-alarm&quot;&gt;Arrival alarm&lt;/h3&gt;

&lt;p&gt;I’m a chronic sufferer of a disease called Scope Creep (this is a joke), so I couldn’t help but add an optional arrival alarm feature. This feature uses the new iOS 26 &lt;a href=&quot;https://developer.apple.com/documentation/AlarmKit&quot;&gt;AlarmKit framework&lt;/a&gt;.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-alarm-2panel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Alarm setting in trip screen and full screen alarm notification&quot; title=&quot;Alarm setting in trip screen and full screen alarm notification&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Alarm setting in trip screen and full screen alarm notification&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;animations-and-transitions&quot;&gt;Animations and transitions&lt;/h3&gt;

&lt;p&gt;I spent a unreasonable amount of time working on the animations and transitions for this app. Since there’s comparatively not a lot of screens or unique transitions to handle, it felt like a good opportunity to push the limits and make the upload experience more delightful. After all, there’s not a &lt;em&gt;ton&lt;/em&gt; of benefit to cost when you consider needing to download an app, and then screenshot or photo your ticket in order to get that slight benefit of not needing to unlock your phone or take your ticket out of your pocket. Hopefully some fun animations add to the motivations to get over that mental hump.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-scanning-transitions.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Scanning transitions and animations&quot; title=&quot;Scanning transitions and animations&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Scanning transitions and animations&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/shinkansen-v1-image-popup.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Image popup transition&quot; title=&quot;Image popup transition&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Image popup transition&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;outlook&quot;&gt;Outlook&lt;/h2&gt;

&lt;p&gt;Shinkansen Live is free on v1.0 release. I have no idea whether the App Store listing will get views, whether the listing will convert to downloads, whether the idea will resonate with people to try, and whether any one-time users will keep the app on their devices and remember to use it. I don’t use the Shinkansen enough to estimate this well.&lt;/p&gt;

&lt;p&gt;Regardless, I’m glad the app exists now. I hope it saves at least a few people that little extra friction in an otherwise smooth Shinkansen journey.&lt;/p&gt;
</description>
        <pubDate>Wed, 24 Dec 2025 05:53:39 -0600</pubDate>
        <link>https://twocentstudios.com/2025/12/24/introducing-shinkansen-live-v1/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/12/24/introducing-shinkansen-live-v1/</guid>
        
        <category>ios</category>
        
        <category>app</category>
        
        <category>shinkansenlive</category>
        
        
      </item>
    
      <item>
        <title>Comprehensible Later: A Read-it-later App for Language Learners</title>
        <description>&lt;p&gt;This post is a short retrospective on Comprehensible Later, my working-title for a read-it-later iOS app prototype I worked on last week. Although it’s currently in private beta on Test Flight, I want to share the motivation and technical challenges I ran into while working on it.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_screens.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Share Extension presented from Safari, main app article list, and article detail screens&quot; title=&quot;Share Extension presented from Safari, main app article list, and article detail screens&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Share Extension presented from Safari, main app article list, and article detail screens&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;TLDR: Comprehensible Later is an iOS app for saving articles natively written in a language you’re learning, with automatic translation via LLM to a simpler version of your target language. The goal is to give you more interesting things to read at a level you can understand without needing to pause to look up every other word.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_japanese_comparison.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Original level and simple level for a Japanese article&quot; title=&quot;Original level and simple level for a Japanese article&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Original level and simple level for a Japanese article&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_english_comparison.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Original level and simple level for an English article (from this blog)&quot; title=&quot;Original level and simple level for an English article (from this blog)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Original level and simple level for an English article (from this blog)&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;what-is-comprehensible-input&quot;&gt;What is Comprehensible Input&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Input_hypothesis&quot;&gt;Comprehensible Input&lt;/a&gt; is part of a language acquisition framework first introduced by &lt;a href=&quot;https://www.sdkrashen.com/&quot;&gt;Dr. Stephen D. Krashen&lt;/a&gt;. The framework states that language is separately &lt;em&gt;acquired&lt;/em&gt; and &lt;em&gt;learned&lt;/em&gt;. &lt;em&gt;Acquisition&lt;/em&gt; happens by ensuring ample input (reading or listening) with the important caveat that the input is &lt;em&gt;comprehensible&lt;/em&gt; at the learner’s current level. &lt;em&gt;Learning&lt;/em&gt; happens through comprehensive study of rules and vocabulary. From this &lt;a href=&quot;https://www.dreaming.com/blog-posts/the-og-immersion-method&quot;&gt;summary&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;When we receive comprehensible input, the conditions are met for our brain to be able to use its natural ability to acquire language, without having to do anything else. There’s no need to study, review vocabulary, or practice anything. Watching and reading itself results in acquisition.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In some senses, this method seems intuitive, not in the least since almost all children acquire language skills before they begin formal teaching. In a second-language context, &lt;a href=&quot;https://en.wikipedia.org/wiki/Graded_reader&quot;&gt;graded readers&lt;/a&gt; – books written for various non-native language levels – have existed for over a century. Wikipedia even has a &lt;a href=&quot;https://simple.wikipedia.org/&quot;&gt;simple English&lt;/a&gt; language variant for many common articles. I’ve occasionally used the modern &lt;a href=&quot;https://www.satorireader.com/&quot;&gt;Satori Reader&lt;/a&gt; service for Japanese graded texts.&lt;/p&gt;

&lt;p&gt;But I think the important part is recognizing exactly &lt;em&gt;how basic&lt;/em&gt; you need to make some input in order for it to be understandable, especially at the absolute-beginner level. In a since-removed introductory YouTube video from the creator, he shows a session of an instructor sitting with a zero-level beginner student, pointing at vivid images in a travel magazine and gesturing heavily while explaining the contents very slowly in the target language as the primary means of bootstrapping.&lt;/p&gt;

&lt;p&gt;Language is so multi-dimensional that it’s incredibly time consuming – both as a creator &lt;em&gt;and&lt;/em&gt; a consumer of materials – to get the exact level of material that is both comprehensible but challenging enough to increase your overall ability. Then add another dimension of &lt;em&gt;motivation&lt;/em&gt;: as a reader, how do you find materials with a subject matter that’s interesting to you and will keep you motivated to push through word-after-word, page-after-page, day-after-day?&lt;/p&gt;

&lt;p&gt;This led to a hypothesis:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Graded readers are usually close to the correct difficulty to facilitate learning, but do not have an audience wide enough to support a variety of interesting subject material.&lt;/li&gt;
  &lt;li&gt;Native materials cover an infinite range of interesting topics, but are infeasible to read until the latest stages of language acquisition.&lt;/li&gt;
  &lt;li&gt;One of the most commonly accepted use-cases for LLMs is text summary and translation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What if we used LLMs to translate any native article on-demand to the user’s exact target language level?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The barriers to this being feasible are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Can an LLM properly translate from e.g. Native Japanese to JLPT N4 level Japanese in a “natural” way – where it is simultaneously challenging, comprehensible, and accurate?&lt;/li&gt;
  &lt;li&gt;Can the translation happen fast enough to fit within a user’s desired language-learning workflow?&lt;/li&gt;
  &lt;li&gt;What additional resources are required to facilitate language learning? In-line dictionary lookup? An SRS system? Customized word lists?&lt;/li&gt;
  &lt;li&gt;What unique points are there to each target language that increase the interface complexity? For example, for Japanese learning, should we include furigana for all potentially unknown kanji?&lt;/li&gt;
  &lt;li&gt;Does it also make sense to allow translation from e.g. Native English to simple Japanese (if the target language is Japanese)?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;specification-for-the-prototype-app&quot;&gt;Specification for the prototype app&lt;/h2&gt;

&lt;p&gt;I’ve used 2025 frontier LLMs to write and translate simple versions of text before, so I’m confident it’s either possible now at a minimally acceptable translation quality or will be in the very near future.&lt;/p&gt;

&lt;p&gt;What I wasn’t confident about is whether it’s cost prohibitive or time prohibitive to use the highest quality reasoning models to do the translation.&lt;/p&gt;

&lt;p&gt;In retrospect, I should have spent at least a little more time doing bench testing on the API versions of various models on a wide array of sample articles. Instead, I took the less (more?) pragmatic route of jumping into the implementation for an app prototype that I could start using ASAP in context, as well as distribute to a few friends.&lt;/p&gt;

&lt;p&gt;My initial thought was that my main source of content would be blog posts and news articles I come across from my everyday feed scrolling. But I also felt I should support translating raw text too, like that from social media posts.&lt;/p&gt;

&lt;p&gt;I considered a Safari Extension to replace existing text on a webpage with the simplified translation, similar to how the built-in translation function in Safari works. But my gut-feeling was that this would be too limiting for language learning use cases. Even reading a text at a simpler level of a target language still takes enough time that it would be better to ensure the user doesn’t feel obligated to read everything at once. Additionally, this wouldn’t work for native text outside of Safari.&lt;/p&gt;

&lt;p&gt;My next thought was a Share Extension. Share Extensions are old iOS technology, but still highly used and useful. In a share extension I could display the translated article content in a dedicated modal and have full control over its presentation and layout.&lt;/p&gt;

&lt;p&gt;However, I also wanted to support the read-it-later use case. Personally, I stumble upon articles when doing feed scrolling sessions when I have a few minutes on the train but don’t necessarily have the time to read the whole article, even in English, at that time. I use Instapaper for read-it-later for English articles and I felt this would be a similarly useful use case to model my app after.&lt;/p&gt;

&lt;p&gt;With that in mind I got to work on the actual prototype with the following initial spec:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A native app that:
    &lt;ul&gt;
      &lt;li&gt;keeps a list of articles imported from URLs or as raw text.&lt;/li&gt;
      &lt;li&gt;has a detail view that shows both the original and translated versions of the text.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;A share extension that:
    &lt;ul&gt;
      &lt;li&gt;immediately processes the shared URL or text and displays it in the share modal.&lt;/li&gt;
      &lt;li&gt;allows the user to optionally save the translated text in the app for later.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note that as of iOS 18.4, there’s actually &lt;em&gt;another&lt;/em&gt; option for the interface: &lt;a href=&quot;https://developer.apple.com/documentation/TranslationUIProvider/Preparing-your-app-to-be-the-default-translation-app&quot;&gt;TranslationUIProviderExtension&lt;/a&gt;. iOS users can replace Apple’s Translation app with another translation app, meaning the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Translate&lt;/code&gt; menu tooltip can open a third-party app. I have mine set to &lt;a href=&quot;https://www.deepl.com/&quot;&gt;DeepL&lt;/a&gt;. Due to the limitations I’ll discuss later (namely, translation processing time), it doesn’t make sense yet to implement Comprehensible Later as a Translation Extension.&lt;/p&gt;

&lt;h2 id=&quot;implementation&quot;&gt;Implementation&lt;/h2&gt;

&lt;p&gt;I worked through several iterations of a detailed implementation spec with Claude and Codex then set them off to work getting the foundations of the app in place. This wasn’t exactly vibe coding because I specified technologies and packages to use up front and guided their output along the way. But I was still aiming to have the agents create the clay that I’d be molding in a distinct second phase of development.&lt;/p&gt;

&lt;h3 id=&quot;packages&quot;&gt;Packages&lt;/h3&gt;

&lt;p&gt;The key packages that would make this closer to a weekend prototype and not a months-long project were:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/Ryu0118/swift-readability&quot;&gt;swift-readability&lt;/a&gt; - wrapper for Firefox’s &lt;a href=&quot;https://github.com/mozilla/readability&quot;&gt;reader-view parsing library&lt;/a&gt; for stripping down a full page HTML to its essential content.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/mattt/AnyLanguageModel&quot;&gt;AnyLanguageModel&lt;/a&gt; - use any LLM API with Apple’s Foundation Models SDK interface.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/gonzalezreal/swift-markdown-ui&quot;&gt;swift-markdown-ui&lt;/a&gt; - display the full Markdown spec in SwiftUI (note: I later replaced this).&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/steipete/Demark&quot;&gt;Demark&lt;/a&gt; - convert HTML-to-Markdown.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/JohnSundell/Ink&quot;&gt;Ink&lt;/a&gt; - convert Markdown-to-HTML.&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/pointfreeco/sqlite-data&quot;&gt;sqlite-data&lt;/a&gt; - SQLite wrapper for local article storage and observable data layer for the app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;data-flow&quot;&gt;Data flow&lt;/h3&gt;

&lt;p&gt;The initial data flow for articles imported via URL was the following:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Input URL
    ↓ URLSession
HTML Data
    ↓ String(data:)
HTML String
    ↓ Readability
Clean HTML
    ↓ Demark
Markdown
    ↓ AnyLanguageModel
Translated MD
    ↓ swift-markdown-ui
Display
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The initial flow for raw text:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Input Text
    ↓ AnyLanguageModel
Translated MD
    ↓ swift-markdown-ui
Display
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;However, due to limitations with the swift-markdown-ui package, the final version of the prototype uses this flow:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Input URL
    ↓ URLSession
HTML Data
    ↓ String(data:)
HTML String
    ↓ Readability
Clean HTML
    ↓ Demark
Markdown
    ↓ AnyLanguageModel
Translated MD
    ↓ Ink
HTML
    ↓ WebView
Display
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;With the bones of the app architecture and dependencies in place, I began testing and optimizing the data flow.&lt;/p&gt;

&lt;h3 id=&quot;readability&quot;&gt;Readability&lt;/h3&gt;

&lt;p&gt;I found a small bug in the Readability Swift wrapper where the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;baseURL&lt;/code&gt; parameter was inaccessible to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URL&lt;/code&gt;-based initializer &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Readability().parse(url:options:)&lt;/code&gt;. This prevented relative image tags from getting properly resolved to a full address. For example, on my website image tags look like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/images/example.jpg&lt;/code&gt; and are resolved by my browser automatically to be either &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://twocentstudios.com/images/example.jpg&lt;/code&gt; (the real server) or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;http://localhost:4000/images/example.jpg&lt;/code&gt; (my local machine).&lt;/p&gt;

&lt;p&gt;Luckily, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;baseURL&lt;/code&gt; parameter was accessible in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Readability().parse(html:options:baseURL:)&lt;/code&gt; initializer. As a workaround I simply needed to fetch the page data myself with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;URLSession&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;demark&quot;&gt;Demark&lt;/h3&gt;

&lt;p&gt;Demark has two different HTML-&amp;gt;Markdown parsing implementations: heavy-and-accurate or fast-and-inaccurate. Since the HTML is getting pre-processed by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Readability&lt;/code&gt; in advance of being passed to Demark, I’m using the fast-and-inaccurate version that doesn’t load the full page in a headless &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WKWebView&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;anylanguagemodel&quot;&gt;AnyLanguageModel&lt;/h3&gt;

&lt;p&gt;As of iOS 26, Apple’s local Foundation model is slow, not-ubiquitously available on devices, and (arguably) barely functional for most use cases, especially mine. Within a few years I expect it may be useful. Similarly, my impression is that any other MLX-compatible models runnable on an iOS device are not yet accurate or fast enough for my use case.&lt;/p&gt;

&lt;p&gt;Therefore, I grabbed both an OpenAI and Gemini API key and wired them up to AnyLanguageModel for testing. I ran a few trials with the top-tier, mini, and nano variants and decided on defaulting to the mini variant as a compromise between speed, cost, and accuracy. Specifically, Gemini Flash 2.5 is the current default, but I suspect I could spend several weeks creating and running benchmarks across the dozens of closed and open models.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/mattt/AnyLanguageModel&quot;&gt;AnyLanguageModel&lt;/a&gt; made it easy to build a user settings-based model switcher with very little code adjustments required on my side. Technically, Gemini ships an &lt;a href=&quot;https://ai.google.dev/gemini-api/docs/openai&quot;&gt;OpenAI-compatible endpoint&lt;/a&gt; so I could have kept even more of the same codepath. During debugging, I realized that AnyLanguageModel wasn’t passing through the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;instructions&lt;/code&gt; parameter to OpenAI, so I submitted a &lt;a href=&quot;https://github.com/mattt/AnyLanguageModel/pull/20&quot;&gt;quick PR&lt;/a&gt; and Mattt had it merged and version bumped by the next day.&lt;/p&gt;

&lt;p&gt;In a later mini-sprint, I added a full settings screen that allows switching model provider, model, target language, target difficulty, adding custom translation instructions, and even fully rewriting the system prompt. Of course, I would never include all these settings in a production app, but it’s useful for my trusted beta testers to tinker if they so choose.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_settings.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The translation settings screen&quot; title=&quot;The translation settings screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The translation settings screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;By default, my (simple) system prompt is:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Faithfully translate the native-level input markdown text into the target language with the target difficulty level.&lt;/p&gt;

  &lt;p&gt;Be creative in transforming difficult words into simpler phrases that use vocabulary at the target difficulty level. Combine or split sentences when necessary, but try to preserve paragraph integrity.&lt;/p&gt;

  &lt;p&gt;The output format should be standard Markdown including all supported markdown formatting like image/video tags. Preserve all structure from the input (paragraphs, lists, headings, links, images, videos). DO NOT ADD COMMENTARY.&lt;/p&gt;

  &lt;p&gt;Target language: \(targetLanguage)&lt;br /&gt;
Target difficulty level: \(targetDifficulty)&lt;br /&gt;
Additional notes: \(additionalNotes)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I’ll discuss my impressions of the effectiveness of this prompt a little later on.&lt;/p&gt;

&lt;p&gt;Something I noticed almost immediately during testing was that requests were taking at minimum 30 seconds and sometimes over 60 seconds to complete. It didn’t really depend on model size either. I found the same performance characteristics for both OpenAI and Gemini APIs direct from first-party servers. I thought it might be the streaming API or perhaps some configuration in AnyLanguageModel I was not in control of, so I switched back to the single-request version. It didn’t help. I also began testing the same prompt and inputs from the API sandbox pages like &lt;a href=&quot;https://platform.openai.com/chat/edit&quot;&gt;OpenAI’s playground&lt;/a&gt; and &lt;a href=&quot;https://aistudio.google.com/u/1/prompts/new_chat&quot;&gt;Google’s AI Studio&lt;/a&gt; and saw basically the same results.&lt;/p&gt;

&lt;p&gt;Although the slow translation speed is a pretty substantial blocker, I felt like, at least temporarily, I could work around it in the UX by leaning into the read-it-later nature of the app. I added support for Apple’s &lt;a href=&quot;https://developer.apple.com/documentation/backgroundtasks&quot;&gt;Background Tasks&lt;/a&gt; API so there was a greater chance that articles added early in the day would be ready to read by the time the user opened the app.&lt;/p&gt;

&lt;h3 id=&quot;app-ui&quot;&gt;App UI&lt;/h3&gt;

&lt;p&gt;With the translation flow in place, I began shaping the app UI.&lt;/p&gt;

&lt;p&gt;The list of articles was simple enough. I held off on adding lots of important, but not urgent, contextual actions like archiving and deleting from the list view.&lt;/p&gt;

&lt;p&gt;I did add both “import from pasteboard” and “import from free text” buttons to the toolbar.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_article_list.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The article list view showing toolbar buttons for importing from pasteboard and free text&quot; title=&quot;The article list view showing toolbar buttons for importing from pasteboard and free text&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The article list view showing toolbar buttons for importing from pasteboard and free text&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_import_screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The import URL/text screen&quot; title=&quot;The import URL/text screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The import URL/text screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I spent more time on the article detail view. Initially, it displayed the title, import state, and translated article. My focus for adding actions was to facilitate debugging primarily for myself and secondarily for my beta testers. This meant buttons for copying the original article text, copying translated article text, deleting an article, opening the original link, and retrying the translation (with different settings).&lt;/p&gt;

&lt;p&gt;After some initial usage, I realized I wanted to see the original text and the translated text side-by-side so that I could compare the language usage by sentence and paragraph.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_article_detail.jpg&quot; width=&quot;&quot; height=&quot;500&quot; alt=&quot;Article detail view with options to display original and translated text&quot; title=&quot;Article detail view with options to display original and translated text&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Article detail view with options to display original and translated text&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_article_actions.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Supported actions for article detail&quot; title=&quot;Supported actions for article detail&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Supported actions for article detail&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_debug_info.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Debug info viewer&quot; title=&quot;Debug info viewer&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Debug info viewer&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;However, the most time-consuming and impactful change was the markdown display system. This was a tough decision, but I think ultimately necessary for the first version.&lt;/p&gt;

&lt;p&gt;Originally, I was planning to use &lt;a href=&quot;https://github.com/gonzalezreal/swift-markdown-ui&quot;&gt;swift-markdown-ui&lt;/a&gt; to display the translated markdown text in SwiftUI. This implementation was basically plug-and-play, rendered exactly as I wanted, supported images out of the box, and was performant. However, the &lt;a href=&quot;https://github.com/gonzalezreal/swift-markdown-ui/issues/264&quot;&gt;one fundamental and unsolvable issue&lt;/a&gt; is that &lt;strong&gt;SwiftUI Text only supports paragraph level copy support and does not support character-level or word-level selection&lt;/strong&gt;. For language learning, I absolutely need the ability to select a word and use the context menu tooltip action “Look Up” or “Translate” or “Copy” buttons. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;swift-markdown-ui&lt;/code&gt; would not be able to support this and I needed to research other solutions.&lt;/p&gt;

&lt;p&gt;I spent nearly a full day researching and experimenting with other Markdown solutions. My second preference was to convert Markdown to AttributedString either &lt;a href=&quot;https://developer.apple.com/documentation/foundation/instantiating-attributed-strings-with-markdown-syntax&quot;&gt;natively&lt;/a&gt; or &lt;a href=&quot;https://github.com/madebywindmill/MarkdownToAttributedString&quot;&gt;with a package&lt;/a&gt;, then display the AttributedString in a &lt;a href=&quot;https://github.com/kevinhermawan/SelectableText&quot;&gt;SwiftUI-wrapped UITextView&lt;/a&gt; with selection enabled but editing disabled. However, both the native and package versions of AttributedString initialization failed at properly respecting whitespace, newlines, and supporting images. My estimation was that it’d take significantly more time for me to grok the full Markdown spec, all the underlying packages, and then implement the required patches than I was willing to spend for a prototype.&lt;/p&gt;

&lt;p&gt;Therefore, I pivoted to using a browser-based target view instead. iOS 26 was blessed with &lt;a href=&quot;https://developer.apple.com/documentation/webkit/webview-swift.struct&quot;&gt;WebView&lt;/a&gt;, a modern SwiftUI-native implementation of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UIWebView&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WKWebView&lt;/code&gt; UIKit views before it. With a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WebView&lt;/code&gt; as the new target, I used &lt;a href=&quot;https://github.com/JohnSundell/Ink&quot;&gt;Ink&lt;/a&gt; to convert the LLM output Markdown back to HTML, added a barebones stylesheet, and loaded these contents. I don’t love using a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WebView&lt;/code&gt; for this use case since it’s comparatively heavy, has plenty of rendering quirks (like occasional white background flashes), and requires a full screen layout. But at the moment it’s the least-worst option.&lt;/p&gt;

&lt;h3 id=&quot;share-extension-and-action-extension&quot;&gt;Share Extension and Action Extension&lt;/h3&gt;

&lt;p&gt;Unfortunately, the slow translation speed meant some of the complexity of creating a fully-featured Share Extension was in vain; it didn’t make sense for the user to wait 30-60 seconds for the share extension to load a preview of the article content like I’d originally planned.&lt;/p&gt;

&lt;p&gt;My initial vision was to load a one-page preview of the translation as quickly as possible. Then, I’d allow the user to tap a button to continue viewing the full translation in line. Or at any time they could tap a button to save the article URL (or raw text) to the main app to read later. I was planning on having an “open in app” button too, but as far as I can tell it’s not supported to open an app directly from a Share Extension.&lt;/p&gt;

&lt;p&gt;I kept the full functionality of the share extension intact in case I can solve the translation speed issue in the future. But as another workaround, I added an Action Extension. An Action Extension appears in the bottom section of the system share sheet. Like a Share Extension it can also present custom UI, however since I already have a Share Extension I made my Action Extension have no UI and immediately save the URL to the app.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_share_sheet.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;iOS share sheet showing both the Share Extension and Action Extension for quickly saving articles&quot; title=&quot;iOS share sheet showing both the Share Extension and Action Extension for quickly saving articles&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;iOS share sheet showing both the Share Extension and Action Extension for quickly saving articles&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_share_extension.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Share Extension with translation complete&quot; title=&quot;Share Extension with translation complete&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Share Extension with translation complete&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;import-flow&quot;&gt;Import flow&lt;/h3&gt;

&lt;p&gt;App Extensions can share data on device with the main app using an &lt;a href=&quot;https://developer.apple.com/documentation/Xcode/configuring-app-groups&quot;&gt;App Group&lt;/a&gt;. When the user indicates they want to add the URL or raw text to the app, the Extension serializes an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; model to a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;json&lt;/code&gt; and writes a new file to the App Group. The main app monitors the shared App Group directory for new files. When it detects a new file, it adds the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; to the app’s SQLite database. If the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; already finished translation, it will include the translated markdown and no further processing is necessary. Otherwise, it will be queued for processing.&lt;/p&gt;

&lt;p&gt;I chose not to share the SQLite database between the main app and the extensions because, since the app and extensions are separate processes, there are &lt;a href=&quot;https://swiftpackageindex.com/groue/GRDB.swift/v7.8.0/documentation/grdb/databasesharing&quot;&gt;myriad issues&lt;/a&gt; with using SQLite in this way. Since data sharing is one way (from extension to app) there’s no need to introduce that complexity.&lt;/p&gt;

&lt;p&gt;Adding articles from the main app instance skips the file encoding/decoding step and simply writes a new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; to the database.&lt;/p&gt;

&lt;p&gt;The processing code is admittedly a bit fragile, but in testing has worked well enough that I haven’t felt an immediate need to rewrite it. It uses an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;enum Status&lt;/code&gt; stored alongside each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt; in the database in order to manage the translation queue, including failures. &lt;a href=&quot;https://github.com/pointfreeco/sqlite-data&quot;&gt;SQLiteData&lt;/a&gt; supports observation, so both the article list view and the article detail view are always up to date on an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Article&lt;/code&gt;’s status.&lt;/p&gt;

&lt;h3 id=&quot;localization&quot;&gt;Localization&lt;/h3&gt;

&lt;p&gt;Localizing a prototype would be something I’d never consider doing before the advent of coding agents. The actual act of translation between a base language and another language is insignificant compared to the amount of additional tooling and operational complexity of introducing localization keys, adding comments, handling interpolation, handling pluralization rules, handling error messages and other strings generated deep in business logic, and handling the indirection involved in looking up the values for the keys. The new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xcstrings&lt;/code&gt; file’s autogeneration definitely helps ease the burden. But it’s at least an order of magnitude more work in my opinion.&lt;/p&gt;

&lt;p&gt;All that said, coding agents can automate enough of this work that I added full localization support for Japanese for one of my beta testers who wanted to try the app for converting English to simple English. I’m still cognizant of the ongoing support complexity full localization adds to a prototype, but for now it’s not a decision I regret.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/comprehensible_later_japanese_localization.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Various screens with Japanese localization&quot; title=&quot;Various screens with Japanese localization&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Various screens with Japanese localization&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;impressions-so-far&quot;&gt;Impressions so far&lt;/h3&gt;

&lt;p&gt;What I’ve learned so far is that the prompt needs to be more customized to each target language and should probably go as far as including an allow-list of words to use, especially for the most basic target difficulties.&lt;/p&gt;

&lt;p&gt;I’ve found the models have a hard time with native Japanese news articles. Something about the language is just so dense that my first prompt attempt does not push the model to simplify enough.&lt;/p&gt;

&lt;p&gt;Similar to what I’ve found with even commercial apps like Instapaper, a large percentage of sites now have enough paywall or otherwise reader-hostile javascript that it’s not enough to fetch a simple URL directly from the source. I’m not ready to handle the endless, unforgiving work of handling all the edge cases of the open web, so URL fetching is going to be best effort for the foreseeable future.&lt;/p&gt;

&lt;p&gt;The Readability library itself is not perfect at parsing out text from pages that aren’t obviously written as “articles”. This isn’t all that different from the built-in Safari reader mode which isn’t universally supported across the entire web.&lt;/p&gt;

&lt;p&gt;Seeing some of my blog posts in super-simple English was really fun. One of my ongoing goals is to write simpler without giving up my voice, so seeing how an LLM breaks up my sentences and phrases and clauses is enlightening (of course, not at all related to the use case the prototype was built for).&lt;/p&gt;

&lt;p&gt;For Japanese, there’s some unpredictability on how the LLM deals with kanji. Usually it includes kanji as is, but sometimes it will add the reading in parentheses directly after for literally every word. For example, “果物（くだもの）を食べる（たべる）”. Native ruby/furigana support would be ideal, and possibly easier using HTML than &lt;a href=&quot;https://github.com/ApolloZhu/RubyAttribute&quot;&gt;AttributedString&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;whats-next&quot;&gt;What’s next?&lt;/h2&gt;

&lt;p&gt;Comprehensible Later is on Test Flight in private beta with myself and a few friends. I’m planning on collecting feedback and evaluating the app’s potential for wider release. It could take another generation or two of LLM. It could take as long as waiting for local models to improve. Or the entire concept could be flawed. I’m not sure yet. But that’s what the prototype is for.&lt;/p&gt;

&lt;p&gt;Regardless of the result, it was of course a good learning experience to see what it’s like to build a read-it-later service for iOS in 2025.&lt;/p&gt;
</description>
        <pubDate>Sat, 15 Nov 2025 05:57:31 -0600</pubDate>
        <link>https://twocentstudios.com/2025/11/15/comprehensible-later-read-it-later-for-language-learners/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/11/15/comprehensible-later-read-it-later-for-language-learners/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>comprehensiblelater</category>
        
        <category>app</category>
        
        
      </item>
    
      <item>
        <title>Vibe Coding a Rental Apartment Search Management App</title>
        <description>&lt;p&gt;I’ve been apartment hunting here in the Tokyo-area with my girlfriend. We’ve been sending links to various rental property listings back and forth in LINE (messaging app) and emailing with brokers. In a chat interface, it was hard keeping up with the status of each of the properties we’d seen, we wanted to see, we’d inquired about, etc. Classic project management problem.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-suumo-listing-example.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Example SUUMO property listing page slightly edited for clarity&quot; title=&quot;Example SUUMO property listing page slightly edited for clarity&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Example SUUMO property listing page slightly edited for clarity&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I decided to skip the step of putting each property into a shared spreadsheet and jump straight to vibe coding a web app with Claude Code. I’ve never worked on a full-stack TypeScript app before, and my impression is that LLMs are most proficient at it, so that’s what I went with.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-finished-desktop-interface.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Finished desktop interface for Bukkenlist&quot; title=&quot;Finished desktop interface for Bukkenlist&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Finished desktop interface for Bukkenlist&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My goal was to create a shared space where we could keep track of the status of listings, add new ones easily, do calculations like 2-year amortized cost, keep a notes and ratings field for each of us, see all the salient points of a property at a glance, and archive properties that we decide against or are already taken.&lt;/p&gt;

&lt;p&gt;After a day of work, it supported scraping SUUMO listings and worked on mobile and desktop web. Another 2 half-days of work and it supports 4 listing sites, maps, expired listings, and English/Japanese localization. I called it Bukkenlist 物件リスト.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-day1-and-final-comparison.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;App progression from end of day 1 (left) to final polished version (right) - there&apos;s not much visual difference&quot; title=&quot;App progression from end of day 1 (left) to final polished version (right) - there&apos;s not much visual difference&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;App progression from end of day 1 (left) to final polished version (right) - there&apos;s not much visual difference&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This was “vibe coding” in the customary definition of “not looking at the generated code at all”. I see the code scrolling past in the terminal window but I’m letting Claude commit it after I check that the rendered result looks and works as intended in the browser window. For this project, I’m playing the role of product manager and QA engineer. However, I did make the decisions about using SQLite for storage, the schema, and the deployment strategy. And I helped Claude dig itself out of holes in the way only an engineer can.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The TL;DR:&lt;/strong&gt; In 2 working days I produced a completely functional web app with much better usability than the spreadsheet it would compete with. Using an AI tool like Claude Code aimed at professionals, it’s hard for me to imagine someone with no coding background being able to get to the same finish line I did. But with the existing cadre of no-code AI tools, perhaps this would be a perfectly scoped project.&lt;/p&gt;

&lt;h2 id=&quot;the-full-development-process&quot;&gt;The full development process&lt;/h2&gt;

&lt;p&gt;I have a Claude Code $100/mo Max subscription. I used the pattern of using “plan mode” with Opus aggressively to ensure proper context gathering and then “accept edits” mode with Sonnet to execute the plan. These were long sessions, so I actually blew through my usage limits once or twice with 1 or 2 hours remaining (with my usual Swift projects I hadn’t hit the Max limit for Sonnet before). At those times, I switched over to the nascent OpenAI Codex CLI (with a $20/mo Pro plan) to see how it did. Everything about Codex still feels months behind Claude Code, but it did handle some of the tasks I threw at it well enough.&lt;/p&gt;

&lt;h3 id=&quot;day-1&quot;&gt;Day 1&lt;/h3&gt;

&lt;p&gt;Learning from some &lt;a href=&quot;/2025/06/22/vinylogue-swift-rewrite/&quot;&gt;past experiments&lt;/a&gt;, I decided this time to be more intentional with my initial getting-started prompts. I didn’t dump my entire vision for the app onto the model and have it create a full product requirements doc and phase-by-phase development plan. I thought staying in the loop would ensure the best chance of success and even minor scalability.&lt;/p&gt;

&lt;p&gt;SUUMO listing scraping was the most risky part, so I had it start by creating some infrastructure around fetching and parsing the HTML for a few example listings and comparing the results with the values I’d plucked out by hand.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-console-parsing-results.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Console showing results from the initial SUUMO listing parsing&quot; title=&quot;Console showing results from the initial SUUMO listing parsing&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Console showing results from the initial SUUMO listing parsing&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Only after the parsing seemed relatively robust did I have Claude create the initial structure of the Express.js backend and React frontend. It used VITE but I only sort of know what role that plays. The first renderable version was a text field for the SUUMO URL, a submit button, and then a list of the keys and values parsed out.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-first-working-interface.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;First working interface with URL input field and parsed listing key/values&quot; title=&quot;First working interface with URL input field and parsed listing key/values&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;First working interface with URL input field and parsed listing key/values&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then it was time to add persistence. This was the part where I &lt;em&gt;should&lt;/em&gt; have first decided on hosting, got that set up, &lt;em&gt;then&lt;/em&gt; decided on the most low maintenance storage solution. Instead, I chose SQLite, which I’ve been interested in lately and have &lt;a href=&quot;/2025/07/02/swift-vapor-fly-io-sqlite-config/&quot;&gt;already deployed&lt;/a&gt; successfully on Fly.io.&lt;/p&gt;

&lt;p&gt;With my engineer hat on, I made the initial decision to have Claude go with a mixed schema-less approach, storing a generous amount of metadata about each property in named columns, but then having a dumping ground JSON column with all the parsed key/value data. Hard to say whether that’s made my life easier or harder while adding new listing sources. For a personal project with 2 users, I think it was a fine decision. For a real production site, I can already tell it would be a nightmare to maintain.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Final schema of the `scrapes` table
     cid  name           type     notnull  dflt_value  pk
     ---  -------------  -------  -------  ----------  --
     0    id             INTEGER  0                    1
     1    url            TEXT     1                    0
     2    property_name  TEXT     0                    0
     3    scraped_data   TEXT     1                    0
     4    created_at     INTEGER  1                    0
     5    status         TEXT     0                    0
     6    archived       INTEGER  1        0           0
     7    kiyoko_notes   TEXT     0                    0
     8    chris_notes    TEXT     0                    0
     9    kiyoko_rating  INTEGER  0                    0
     10   chris_rating   INTEGER  0                    0
     11   source_site    TEXT     1        &apos;suumo&apos;     0
     12   color_id       TEXT     0                    0
     13   latitude       REAL     0                    0
     14   longitude      REAL     0                    0
     15   expired        INTEGER  1        0           0
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;From there I had Claude build out the master/detail list in desktop mode. It had little trouble putting together a passible design.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-initial-master-detail-view.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Initial master-detail interface showing property list and selected property detail&quot; title=&quot;Initial master-detail interface showing property list and selected property detail&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Initial master-detail interface showing property list and selected property detail&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I added delete and refresh support since those were helpful in manual testing. Refresh should re-run the scraping and parsing and replace all the fields with the freshly parsed content.&lt;/p&gt;

&lt;p&gt;Then it was kind of the fun part: pushing around the fields in the UI to make it more pretty and readable.&lt;/p&gt;

&lt;p&gt;Next I had to add image carousel support which was surprisingly easy. I prompted Claude to do some extra research for best practices before deciding on a solution.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-ui-reorganizing-carousel.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Reorganized UI with image carousel&quot; title=&quot;Reorganized UI with image carousel&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Reorganized UI with image carousel&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I added deterministic unique color generation for each property based on its unique ID mapped to a hue value 0-359 in HSV. I use this technique often in projects as a nice touch to make resources easier to identify.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-color-id-support.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Properties with unique color IDs for intuitive identification&quot; title=&quot;Properties with unique color IDs for intuitive identification&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Properties with unique color IDs for intuitive identification&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I can’t sightread Japanese as fast as I can English, so I had Claude add full UI localization in both English and Japanese to the entire app and have it save the preference in local storage. This helped speed up QA of parser errors going forward.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-localization-support.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;English/Japanese localization toggle&quot; title=&quot;English/Japanese localization toggle&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;English/Japanese localization toggle&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I wanted to highlight the at-a-glance parts each property that were especially important to the two of us, so I added those in big font next to the image carousel.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-at-a-glance-properties.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;First version of the at-a-glance property details&quot; title=&quot;First version of the at-a-glance property details&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;First version of the at-a-glance property details&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;From here it was a lot of polish. I felt like I was in full product manager flow-state, just picking off the next obvious change in the UI and prompting Claude to have a go at it.&lt;/p&gt;

&lt;p&gt;The whole point of the app was to facilitate our apartment search process, which ultimately meant appending our own information to listings. I added an open-ended status field to track things like “requested viewing” or “viewing on 8/24”. I added an open-ended notes field for each of us, then a 4-level rating system. In the notes field, we’ve been adding merits/demerits. The rating system is an easy way to clearly communicate our enthusiasm towards each property and see it at a glance in the sidebar.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-notes-and-ratings.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;First version of the notes and rating system for each (hardcoded) user&quot; title=&quot;First version of the notes and rating system for each (hardcoded) user&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;First version of the notes and rating system for each (hardcoded) user&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A cool feature I’d very loosely prototyped with a single ChatGPT query a few days previously was an “amortized cost” field, calculated from several fields. There are so many disparate fees for each listing (monthly rent, management fees, security deposit, key money, parking fee, etc.) that it’s hard to do an apples-to-apples comparison of how expensive properties actually are. It’s elementary school math, but just annoying to do.&lt;/p&gt;

&lt;p&gt;It was pretty simple to add this field: parse out the semantic values, multiply the monthly costs by the lease term, add the one-time costs, then divide by the lease term to get the overall monthly cost.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-all-in-cost.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Amortized cost calculation for true monthly expense comparison&quot; title=&quot;Amortized cost calculation for true monthly expense comparison&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Amortized cost calculation for true monthly expense comparison&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I was on the fence about whether to build out a full user table and authentication system. It may have been worth seeing whether Claude could have one-shotted multi-user support. Instead, I opted for a simple password auth and full editing support for any field. I’m pretty happy with this solution and proud of myself for not going overboard on the spec. It’s much easier to share a single password than deal with a create account flow on multiple devices or while on the go. I set some strict rate limits for password attempts and page requests in general and know that if the site gets hacked and trashed somehow it’s not a huge deal.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-login-screen.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Simple password login screen to gate the whole app&quot; title=&quot;Simple password login screen to gate the whole app&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Simple password login screen to gate the whole app&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It was finally time for deployment! I was definitely procrastinating on this, but I wanted to get it online before I went to bed.&lt;/p&gt;

&lt;p&gt;Looking into some of the common free hosting services that target JS, I realized Vercel was serverless and I’d need a different solution for the SQLite storage. I could have tried Turso for SQLite hosting, but signing up for 2 services felt like too much complexity. I went back to Fly.io since I have some experience with them and an existing account and all the CLI stuff installed.&lt;/p&gt;

&lt;p&gt;Claude was happy to set up all the deployment stuff and mostly one-shotted it. The big issue came with my underlying scraping implementation. Scraping was based on &lt;a href=&quot;https://playwright.dev/&quot;&gt;Playwright&lt;/a&gt; which needs to spin up a full Chromium instance and that takes 10+ seconds on a 2 GB machine. I have aggressive suspension set for my Fly.io instances which means this heavy startup cost needs to be paid every time a new listing is added. I also didn’t want to pay for a full 2 GB machine on Fly.&lt;/p&gt;

&lt;p&gt;I started another vibe spike to replace Playwright with Puppeteer and a lighter Chromium fork based on &lt;a href=&quot;https://vercel.com/guides/deploying-puppeteer-with-nextjs-on-vercel&quot;&gt;this guide&lt;/a&gt; from Vercel. With a lot of trial and error (including rewriting the parser), I got the memory requirement down to 512 MB at the cost of 30+ second scraping.&lt;/p&gt;

&lt;p&gt;At this point, I took a step back and thought about whether I actually needed a full Chromium-based scraper. After all, I’d never actually verified whether these sites were doing enough JS rendering to require it. I don’t have a lot of experience with scrapers and this project was an attempt to fix that. I had Claude do yet another spike with some initial research as to what the most common tools were for low-resource scraping and it chose &lt;a href=&quot;https://github.com/jsdom/jsdom&quot;&gt;JSDOM&lt;/a&gt;. After rewriting the parser yet again, it turned out this worked fine and was super fast and easily deployable to a tiny 256 MB machine.&lt;/p&gt;

&lt;p&gt;If I’d have tried deploying immediately after finishing the very first version of the scraper, I’d have had a much easier time. But I also realized I wouldn’t have had much invested at this point, and my motivation to continue may not have survived this deployment slog. An interesting paradox! In theory I would have saved hours, but in practice I may not have shipped anything. It’s also possible I would have chosen a different deployment service that affected my choice of persistence solution, etc. Decision ordering really matters, and I’m trying to get better at it. But also, LLMs make spikes, backtracking, and rewrites so low-cost/low-effort that as long as you’re willing to ignore sunk costs and your motivation survives you can end up with much more optimal solutions in the long run.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-deployed-production-end-day1.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;App successfully deployed to production at the end of day 1 (Japanese interface)&quot; title=&quot;App successfully deployed to production at the end of day 1 (Japanese interface)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;App successfully deployed to production at the end of day 1 (Japanese interface)&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My first commit was 4pm on Saturday and my last commit before going to sleep was 6am Sunday. I’d (re)watched 3 seasons of Silicon Valley in the background. I sent my girlfriend a link and the password and went to bed.&lt;/p&gt;

&lt;h2 id=&quot;day-2--3&quot;&gt;Day 2 &amp;amp; 3&lt;/h2&gt;

&lt;p&gt;I woke up a couple hours later and made pancakes and got a message from my girlfriend with links to listings from 2 other services. So it was time to add support for more listing sources!&lt;/p&gt;

&lt;p&gt;I had Claude do a refactor of the scraper in preparation for adding multi-service support. Again, this was vibe coding so I had no idea how well it did, but I trusted it. This took about an hour. I gave it a link to a new listing and had it run its scraping parsing iterative procedure to write an initial version of the parser. According to the git logs it took about an hour to write the two new scrapers and a guide for itself for writing future scrapers.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-multi-source-support.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;Multi-source support showing listings from different rental websites&quot; title=&quot;Multi-source support showing listings from different rental websites&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Multi-source support showing listings from different rental websites&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I realized the two of us would need to use my new site from our iPhones, so I added mobile support. This was way way faster than I expected. It took a bit more Claude coercing the next day to get it fully optimized, but the first attempt was definitely usable.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-initial-mobile-interface.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Initial mobile interface with separate property list screen (left) and property detail screen (right)&quot; title=&quot;Initial mobile interface with separate property list screen (left) and property detail screen (right)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Initial mobile interface with separate property list screen (left) and property detail screen (right)&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Sunday was a half-day. On Monday, I put in another half-day optimizing the mobile layout, adding another source, adding listing expiry support, maps support, and deep linking support.&lt;/p&gt;

&lt;p&gt;Maps support was actually the most difficult single-feature I’d vibe coded for the whole project. Scraping the coordinates for a listing wasn’t too bad, but deciding on the maps provider and implementation was difficult.&lt;/p&gt;

&lt;p&gt;At first, I was planning on rendering out a static map image on the backend during property add because it seemed simplest and lowest cost. But since I already have an Apple Developer account, using the MapKit JS API was free so I went with that.&lt;/p&gt;

&lt;p&gt;Turns out that Claude had a pretty awful time integrating the MapKit JS library. This is where I ran into a lot of frustration with vibe coding and not having any idea how React works, how JS library loading should work, how environment variables work on the client side, how JS library token authorization should work, and more. I was in thrashing mode with Claude, watching it implement “fixes” that seemed dubious even to me, a JS novice, and inevitably did not work at all.&lt;/p&gt;

&lt;p&gt;I had to get a lot more hands on and spent a long dev cycle restarting the dev server, deploying to production over and over, copying and pasting browser console logs, and adding and removing secrets from the Fly.io admin page.&lt;/p&gt;

&lt;p&gt;In the end, we got it working, but it’s hard for me to say &lt;em&gt;why&lt;/em&gt; Claude struggled so hard with this particular task and how I could have approached it differently.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/bukkenlist-maps-support.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Map showing the property location and its relationship to closest station&quot; title=&quot;Map showing the property location and its relationship to closest station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Map showing the property location and its relationship to closest station&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;iteration-loop-workflow&quot;&gt;Iteration loop workflow&lt;/h2&gt;

&lt;p&gt;For my overall development experience, the local build and serve process was more effortless than iOS, but I found Claude’s new background Bash processes feature frustrating.&lt;/p&gt;

&lt;p&gt;Claude would write some code, start a background server, test the code by making some calls to the server, make some code changes, then not realize that the server needed to be restarted and get stuck in a “why isn’t the output changing” loop. I’d need to keep an eye out for this and intervene.&lt;/p&gt;

&lt;p&gt;After a while I took control of starting/stopping the dev server in a separate terminal window, but Claude would ignore this and keep trying to do its own thing. If I was working on this full time I would certainly spend some time making this flow more efficient. I dealt with the paper cuts.&lt;/p&gt;

&lt;h2 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h2&gt;

&lt;p&gt;We still have at least a few weeks left in the apartment hunting process. The less we need to use this app the happier I will be.&lt;/p&gt;

&lt;p&gt;For the time investment, I’d consider this app overkill. It’s useful individually, but for a production app it wouldn’t work as scraping is presumably against the TOSes. The parsing is sloppy. There’s no user account system. There’s no sharing system. There’s no base SaaS functionality. Even individually, it would have taken a lot less time to simply enter the key fields into a spreadsheet manually.&lt;/p&gt;

&lt;p&gt;But this was a good experience seeing how feasible vibe coding is for someone with my background. There were several points in the process where I hit that beautiful flow state and really loved it. But there were also stretches where Claude was thrashing and I was losing my patience. Or when I was the tool of the LLM clicking boxes in admin panels or doing visual QA while it was doing the interesting architecture and coding tasks.&lt;/p&gt;

&lt;p&gt;One thing’s for certain: I never would have attempted a project like this without an LLM agent. If I did, I probably would have lost motivation after finishing the first scraper. I probably would have used a technology I already knew even if it was not the most prudent choice in 2025.&lt;/p&gt;

&lt;p&gt;I’d like to try some of the more “batteries included” vibe coding environments (e.g. Lovable, Bolt, Replit, V0) to do a similarly scoped project in the near future. I’m most comfortable with Claude Code at the moment because I’m used to the freedom + sharp edges combination. But it’s hard for me to imagine a non-programmer or even a junior programmer being able to dig themselves out of the holes I found myself in a few times. There’s just a &lt;em&gt;lot&lt;/em&gt; to know still to get an MVP designed, developed, and deployed.&lt;/p&gt;

&lt;p&gt;I can see how using a vibe coding environment with less freedom but more well-paved integrations could prevent dead-ends and thrashing and bad developer experience. Maybe within the year both Claude Code and the vibe coding platforms will have converged into providing decent enough support for users of any background.&lt;/p&gt;

</description>
        <pubDate>Mon, 18 Aug 2025 12:46:25 -0500</pubDate>
        <link>https://twocentstudios.com/2025/08/18/vibe-coding-a-rental-apartment-search-management-app/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/08/18/vibe-coding-a-rental-apartment-search-management-app/</guid>
        
        <category>claude</category>
        
        <category>web</category>
        
        <category>react</category>
        
        <category>typescript</category>
        
        <category>vibecoding</category>
        
        <category>app</category>
        
        <category>javascript</category>
        
        <category>nodejs</category>
        
        <category>flyio</category>
        
        <category>sqlite</category>
        
        <category>debugging</category>
        
        
      </item>
    
      <item>
        <title>Reintroducing Technicolor: Binge Watch with Friends Over Space and Time</title>
        <description>&lt;p&gt;Although it’s still in beta, I think it’s a good time to reintroduce my web app side project called Technicolor.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-beta-icon.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Technicolor beta app icon&quot; title=&quot;Technicolor beta app icon&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Technicolor beta app icon&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Technicolor is a chat app tailored for watching TV shows with friends asynchronously. I’ve found it to be a great way to stay in touch with friends in other cities/states/countries.&lt;/p&gt;

&lt;p&gt;The current version of Technicolor is a native SwiftUI app available on iOS 17.4+ devices and macOS.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-beta-overview.png&quot; width=&quot;800&quot; height=&quot;&quot; alt=&quot;Technicolor app overview showing dashboard, room interface (redacted), and media inspector screens&quot; title=&quot;Technicolor app overview showing dashboard, room interface (redacted), and media inspector screens&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Technicolor app overview showing dashboard, room interface (redacted), and media inspector screens&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;how-it-works&quot;&gt;How it works&lt;/h2&gt;

&lt;p&gt;Technicolor is a multi-player experience.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Gather some friends&lt;/strong&gt;: Technicolor is optimized for IRL close friends. A group of 2-4 friends is most optimal.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Choose a TV show to watch&lt;/strong&gt;: The best picks are shows that you can imagine generating a lot of reactions or hot takes, but choosing the right show is more art than science in my experience so far. Technicolor doesn’t embed/include media, so all members must have access to a streaming service or file-based media.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Create a Room&lt;/strong&gt;: Each episode discussion lives in its own chat room. Technicolor uses &lt;a href=&quot;https://www.themoviedb.org/&quot;&gt;TMDB&lt;/a&gt; as its media metadata provider.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Watch the episode on your own schedule&lt;/strong&gt;: Each Room member watches the episode and leaves comments tagged with the timestamp. If you watch first, you lay the foundation for discussion. If you watch later, you’re often replying to existing comments. It’s a unique experience each way.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Mark as watched&lt;/strong&gt;: Tap the “Mark as Watched” button to alert other members via push notification that you’ve finished watching so they can read your comments.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Read and reply&lt;/strong&gt;: Respond to comments from other members. Keep the discussion going for as many turns as you’d like.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Move onto the next episode&lt;/strong&gt;: Technicolor is media-aware and has helpers for quickly creating a Room for the next episode in the current or next season.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;main-features&quot;&gt;Main features&lt;/h2&gt;

&lt;h3 id=&quot;navigate-your-watchlist-in-the-dashboard&quot;&gt;Navigate your watchlist in the Dashboard&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-beta-dashboard.png&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;Technicolor Dashboard showing active rooms organized by TV show&quot; title=&quot;Technicolor Dashboard showing active rooms organized by TV show&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Technicolor Dashboard showing active rooms organized by TV show&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Manage your watchlist on the Dashboard screen. The dashboard intelligently manages active Rooms so you have quick access to episodes you need to watch and those you need to read comments for.&lt;/p&gt;

&lt;p&gt;Rooms are grouped logically by TV show and members.&lt;/p&gt;

&lt;p&gt;Technicolor also supports movie watching groups. All movies watched by the same members are grouped in one section.&lt;/p&gt;

&lt;p&gt;It’s easy to create the next episode by tapping the more button and choosing “Create Room for S03E01”.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-beta-create-next-room.png&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;Create Room for next episode popup&quot; title=&quot;Create Room for next episode popup&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Create Room for next episode popup&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;leave-comments-in-a-room&quot;&gt;Leave comments in a Room&lt;/h3&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-beta-room-interface.png&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;Room interface showing timestamped comments (redacted)&quot; title=&quot;Room interface showing timestamped comments (redacted)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Room interface showing timestamped comments (redacted)&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Comments in a Room are grouped into mini-threads by timestamp.&lt;/p&gt;

&lt;p&gt;There’s a custom control for selecting a timestamp by tapping and dragging like a video scrubber.&lt;/p&gt;

&lt;video src=&quot;/images/technicolor-beta-timestamp-control.mov&quot; controls=&quot;&quot; preload=&quot;none&quot; poster=&quot;/images/technicolor-beta-timestamp-control-poster.png&quot; height=&quot;600&quot;&gt;&lt;/video&gt;

&lt;p&gt;Tap the info button to see a quick overview of metadata about the episode via TMDB.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-beta-info-button.png&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;Episode info screen showing metadata from TMDB&quot; title=&quot;Episode info screen showing metadata from TMDB&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Episode info screen showing metadata from TMDB&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;start-a-new-show&quot;&gt;Start a new show&lt;/h3&gt;

&lt;p&gt;There’s a flow for adding your first Room for a show. Search for a TV Show, Movie, or enter a custom title. Then select which friends you’ll watch with.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-beta-new-room-search.png&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;New Room search screen showing Breaking Bad search results&quot; title=&quot;New Room search screen showing Breaking Bad search results&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;New Room search screen showing Breaking Bad search results&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;friend-management&quot;&gt;Friend management&lt;/h3&gt;

&lt;p&gt;Technicolor has a full mutual-friend management system. You can only create new Rooms with users you have a mutual friendship with (or are already in an existing group with).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-beta-me-screen.png&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;Me screen showing friendship management and settings&quot; title=&quot;Me screen showing friendship management and settings&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Me screen showing friendship management and settings&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;invites&quot;&gt;Invites&lt;/h3&gt;

&lt;p&gt;In this beta phase, Technicolor uses an invite system to control new user sign-ups. A user can create unlimited invite codes to invite their IRL friends.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-beta-invites.png&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;Invite screen for generating invite codes and checking redemption status&quot; title=&quot;Invite screen for generating invite codes and checking redemption status&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Invite screen for generating invite codes and checking redemption status&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;limitations&quot;&gt;Limitations&lt;/h2&gt;

&lt;h3 id=&quot;async-only&quot;&gt;Async-only&lt;/h3&gt;

&lt;p&gt;This native-first version of Technicolor follows a few other variants I’ve created and used over the years (see the History section below).&lt;/p&gt;

&lt;p&gt;In earlier versions, Technicolor could operate as both a live-streaming, synchronous client in addition to a async client. I found that live-streaming support, although kind of cool, didn’t really make sense in the timestamp marked format. Normal chat apps work fine since everyone is synced and reading/writing comments in real time. There’s also not much reason to keep the history around since everyone has already caught up on the comments.&lt;/p&gt;

&lt;p&gt;For this reason, and to keep implementation complexity low, I’ve left out Websocket support. Optimizing the UI and UX for asynchronous makes things much simpler to use, explain, and maintain.&lt;/p&gt;

&lt;h3 id=&quot;apple-platforms-only&quot;&gt;Apple platforms-only&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;Most&lt;/em&gt; of my friends are iOS/macOS users, and since I am an iOS specialist, I’ve decided to only target Apple platforms for now.&lt;/p&gt;

&lt;p&gt;I’m considering a web version for the future to include my Android/Windows friends. But at the moment, it’s already a lot to fill out the feature set and polish the UX for a non-revenue-generating side project.&lt;/p&gt;

&lt;h2 id=&quot;the-future-of-technicolor&quot;&gt;The future of Technicolor&lt;/h2&gt;

&lt;p&gt;I’m planning to do beta testing with my close friends for a while until all the primary flows of the app feel production ready.&lt;/p&gt;

&lt;p&gt;I still have plenty of unimplemented ideas that could improve the commenting-while-watching flow.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;There should be a timer mode that automatically counts up while the episode is running, so you don’t have to choose timestamps manually as often.&lt;/li&gt;
  &lt;li&gt;The TMDB episode detail screen should also load the list of actors and characters in an episode for easy reference. Bonus points for being able to @-mention a character/actor in the chat box with autocomplete support.&lt;/li&gt;
  &lt;li&gt;I’ve implemented subtitle fetching support on the backend, but haven’t thought through exactly how to surface this info in the app. You could optionally attach a line from the subtitles to a new comment. Or subtitles could automatically appear in line near timestamps automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I have reservations about releasing a social network to the general public due to the amount of moderation required. Technicolor is closer to a private chat app than a social network. And with its current user relationship model it’s probably safe enough and niche enough that bad actors can’t wreak too much havoc. But I’d still probably need to implement message reporting, an admin dashboard for moderation, and some more safeguards around spamming. Being iOS/macOS-only also helps ensure that spam is less prevalent than it otherwise might be.&lt;/p&gt;

&lt;p&gt;I could keep the invite system to both limit the growth rate, prevent abuse, and contribute to successful user onboarding.&lt;/p&gt;

&lt;p&gt;I’d probably need to harden the backend API a little more, maybe put Cloudflare in front of it. The Fly.io instance currently goes to sleep when there’s no activity to save money, so I could choose to keep it on. The machine specs are very weak, so I could beef those up as well to ensure my users see the theoretical speed of Swift on the server.&lt;/p&gt;

&lt;h2 id=&quot;a-brief-history-of-technicolor&quot;&gt;A brief history of Technicolor&lt;/h2&gt;

&lt;p&gt;I first wrote about Technicolor back in 2013 in &lt;a href=&quot;/2014/01/26/fall-2013-project-wrap-up/#:~:text=to%20show%20though.-,Technicolor%20TV,-Status%3A%20Under%20Infrequent&quot;&gt;a post about my side projects&lt;/a&gt;.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-3.png&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;Original Technicolor web app dashboard&quot; title=&quot;Original Technicolor web app dashboard&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Original Technicolor web app dashboard&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/technicolor-4.png&quot; width=&quot;600&quot; height=&quot;&quot; alt=&quot;Original Technicolor room interface&quot; title=&quot;Original Technicolor room interface&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Original Technicolor room interface&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The short version is that I moved from Chicago to New York and still wanted a way to watch TV shows with my Chicago friends. We’d still regularly get on video calls for live stream watching, but for other shows we started emailing each other with timestamped comments. These emails started to get unwieldy, and I posited that a simple web app could improve the watching experience while also facilitating episode management outside an email client.&lt;/p&gt;

&lt;p&gt;The first version was implemented as a Ruby on Rails app deployed to Heroku and vending HTML and simple JavaScript. It was browser-only and not well-optimized for mobile devices (at the time, I didn’t own a dedicated TV set and watched most shows on my computer).&lt;/p&gt;

&lt;p&gt;After the initial short development period, Technicolor worked well enough that I didn’t need to do much active development. Additionally, the code I wrote as a hobbyist Rails/JS-dev had already reached the point of complexity where it was no longer easy or safe to make even minor changes.&lt;/p&gt;

&lt;p&gt;In late-2017 I moved to Japan and was motivated again to revive the side project as mobile first. I also wanted to explore a different backend architecture that was type-safe. I briefly started an Elixir backend, and although I already had a little experience with it, it soon became clear that it was too bespoke, especially for a side project I only had occasional time to devote to.&lt;/p&gt;

&lt;p&gt;Swift on the server was starting to get some buzz, and some exploration with Vapor made it seem like there were theoretical merits to having a unified language for back-end and front-end development.&lt;/p&gt;

&lt;p&gt;I started chipping away at development again (while still using the legacy web-app version), but I fell into side-project hell where each time I’d have the motivation to work on it, I’d spend the entire day updating Vapor, migrating Swift, changing hosting providers, or converting to the latest SwiftUI APIs. There was very little forward progress.&lt;/p&gt;

&lt;p&gt;I also fell into various scope-creep traps. Adding the invite system. Adding full mutual friendship support and blocking. My previous episode data provider TVDB switched to a paid-only API, so I descoped that and moved to a Room system that had no media linking.&lt;/p&gt;

&lt;p&gt;Heroku’s free tier was discontinued in 2022, and with it the legacy version of Technicolor went offline.&lt;/p&gt;

&lt;p&gt;Coming off some headwinds with &lt;a href=&quot;/2025/06/22/vinylogue-swift-rewrite/&quot;&gt;my last rewrite experience&lt;/a&gt;, I finally decided to check the status of my codebase after over two years of dormancy. As I suspected, Claude Code made quick work of updating all my iOS code to my preferred architecture and the latest APIs. Unfortunately, the Vapor framework was a lot further behind the Swift 6 migration than I’d hoped, but still has reasonable support for most bread-and-butter web app capabilities.&lt;/p&gt;

&lt;p&gt;I got somewhat lost in the scope creep flow state, tearing through the implementation of all the features that had been rotting on my TODO list for a literal decade. On the user-facing side, I’m especially proud of the very streamlined dashboard layout and the push notifications support (the impetus of the mobile-first rewrite in the first place). On the development tooling side, I’m proud of having a comprehensive server-side test suite and a custom release wizard script that prepares and submits both iOS and macOS versions of the app.&lt;/p&gt;

&lt;h2 id=&quot;tech-stack-details&quot;&gt;Tech stack details&lt;/h2&gt;

&lt;p&gt;As mentioned above, Technicolor’s backend is written using the &lt;a href=&quot;https://vapor.codes/&quot;&gt;Swift Vapor&lt;/a&gt; framework. It’s hosted on &lt;a href=&quot;https://fly.io/&quot;&gt;Fly.io&lt;/a&gt;. The client is an iOS target written in Swift and SwiftUI and supporting iOS 17+. There’s technically a native macOS target via Mac Catalyst, but it actually looks and functions worse than the “Designed for iPad” version, so I’m probably going to deprecate the Mac Catalyst version.&lt;/p&gt;

&lt;p&gt;&lt;del&gt;I’ll do a deeper dive into the tech stack in a future post because I think there’s at least a few interesting and unique points to the architecture.&lt;/del&gt; I go into the technical details in the next post: &lt;a href=&quot;/2025/08/04/full-stack-swift-technicolor-technical-architecture/&quot;&gt;Full-Stack Swift: The Technical Architecture of Technicolor&lt;/a&gt;.&lt;/p&gt;

</description>
        <pubDate>Fri, 25 Jul 2025 09:00:00 -0500</pubDate>
        <link>https://twocentstudios.com/2025/07/25/reintroducing-technicolor-binge-watch-with-friends-over-space-and-time/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/07/25/reintroducing-technicolor-binge-watch-with-friends-over-space-and-time/</guid>
        
        <category>technicolor</category>
        
        <category>app</category>
        
        
      </item>
    
      <item>
        <title>Eki Live - Zero-Touch Assistant for Navigating Tokyo&apos;s Railways</title>
        <description>&lt;p&gt;I’m excited to announce my latest app Eki Like or 駅ライブ in Japanese.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-v1-app-icon.png&quot; width=&quot;&quot; height=&quot;200&quot; alt=&quot;App icon for Eki Live v1.0&quot; title=&quot;App icon for Eki Live v1.0&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;App icon for Eki Live v1.0&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Tokyo-area residents can download it &lt;a href=&quot;https://apps.apple.com/us/app/eki-live/id6745218674&quot;&gt;on the App Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Eki Live tracks the train you’re currently on and shows the current/next station on your route.&lt;/p&gt;

&lt;p&gt;The main interface is a Live Activity:&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-live-activity-en.png&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;Live Activity on the lock screen and Dynamic Island&quot; title=&quot;Live Activity on the lock screen and Dynamic Island&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Live Activity on the lock screen and Dynamic Island&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;But there’s also a more detailed view inside the app:&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-v1-home-en.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Home screen of Eki Live (English version)&quot; title=&quot;Home screen of Eki Live (English version)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Home screen of Eki Live (English version)&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The unique point of Eki Live is that it’s designed as a &lt;strong&gt;zero-touch&lt;/strong&gt; app:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The app runs silently (and lightly) in the background throughout the day.&lt;/li&gt;
  &lt;li&gt;When it detects you’re on a train, the app automatically determines which railway line you’re on and which direction you’re headed.&lt;/li&gt;
  &lt;li&gt;The app starts a Live Activity that appears on your lock screen and Dynamic Island (on supported iPhones).&lt;/li&gt;
  &lt;li&gt;When your trip is over, the Live Activity automatically disappears.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All this happens with &lt;em&gt;zero user interaction&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I’ve been using the app during the last few months of development and I think it’s kind of magical. Please &lt;a href=&quot;https://apps.apple.com/us/app/eki-live/id6745218674&quot;&gt;give it a try&lt;/a&gt; and send me your thoughts at &lt;a href=&quot;support@twocentstudios.com?subject=Eki%20Live%20Feedback&quot;&gt;support@twocentstudios.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you’d like a deep dive into Eki Live, why I made it, its limitations, and what I’ve got planned for it next, please keep reading.&lt;/p&gt;

&lt;h2 id=&quot;limitations&quot;&gt;Limitations&lt;/h2&gt;

&lt;p&gt;Eki Live relies primarily on Apple’s Location Services framework – which itself relies mostly on GPS data. Therefore, Eki Live has very limited operation on underground railway lines. This often includes the many stations that are covered or partially underground, as well as tunnels and dense city-areas.&lt;/p&gt;

&lt;p&gt;For efficient background standby operation, Eki Live uses a Location Services API called &lt;em&gt;significant location changes&lt;/em&gt;. The app stays asleep in the background like all other apps on your device, but is awoken by the system when your device moves some distance from its previous location. When awoken, Eki Live briefly checks whether the device is moving at train-speeds, and if not, it goes right back to sleep. This means that Eki Live will wake up and find your current railway at minimum a few hundred meters after you’ve departed your origin station and sometimes longer. The detection time can be random depending on several factors (whether the system is already using your location, your battery level, etc.).&lt;/p&gt;

&lt;p&gt;When GPS accuracy drops significantly, Eki Live will report stations as &lt;strong&gt;Nearby&lt;/strong&gt; instead of &lt;strong&gt;Next&lt;/strong&gt; or &lt;strong&gt;Now&lt;/strong&gt;. When no new GPS coordinates have been delivered by the system in over 60 seconds, Eki Live will switch to reduced accuracy mode with hatching and a low signal indicator shown in the Live Activity and in the app.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-v1-reduced-accuracy.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Reduced accuracy mode is triggered when no GPS coordinates have been received in 60 seconds or more&quot; title=&quot;Reduced accuracy mode is triggered when no GPS coordinates have been received in 60 seconds or more&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Reduced accuracy mode is triggered when no GPS coordinates have been received in 60 seconds or more&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;As of v1.0, Eki Live will dismiss itself once you’ve stopped moving at train speeds for a period of about 10 minutes. This is only a temporarily limitation and will be improved. If you’re feeling impatient, you can swipe left on the Live Activity on the lock screen to dismiss it.&lt;/p&gt;

&lt;p&gt;Similarly, Eki Live will usually handle above ground transfers within a couple hundred meters of the transfer station, but it depends on how divergent the new railway line is from the previous one. This is also a limitation I think I’ll be able to improve in the near future.&lt;/p&gt;

&lt;p&gt;Differentiating between railway lines that run parallel is difficult for Eki Live’s tracking algorithm without more input than just GPS. At the moment, Eki Live will use data about which stations you’ve stopped at versus which you’ve passed to determine which of up to several parallel railways you’re currently on. But this can take as much time as it takes to get to a few stations. If you’re feeling impatient, you can always tap Eki Live’s Live Activity to open the app and tap another railway candidate.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-v1-railway-selection.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;You can open the app to manually select your current railway if it&apos;s not the top candidate; in this example the Meguro-line&quot; title=&quot;You can open the app to manually select your current railway if it&apos;s not the top candidate; in this example the Meguro-line&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;You can open the app to manually select your current railway if it&apos;s not the top candidate; in this example the Meguro-line&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Eki Live does not currently differentiate between local and express trains for railways that have them. However, Eki Live will sometimes show stations as &lt;strong&gt;Passing&lt;/strong&gt; instead of &lt;strong&gt;Now&lt;/strong&gt; when it has high confidence a station will be passed and not stopped at. This depends on many cars the train has and which car (front or back) you are currently occupying.&lt;/p&gt;

&lt;p&gt;Finally, there is an undocumented hard-limit on the number of Live Activities an iOS app can start during a period of time while the app is in the background. As of iOS 18.4, the limit is 10 times per 24-hour window. This means that Eki Live will only be able to &lt;em&gt;start&lt;/em&gt; 10 Live Activities per day from the background. In practice this should be an incredibly rare occurrence. But if it does happen, the workaround would be to open the app, which would start the Live Activity directly on device.&lt;/p&gt;

&lt;p&gt;My mission from version 1.0 onward is to overcome these limitations one-by-one to make the app fulfill its promise of being truly zero-touch on any railway.&lt;/p&gt;

&lt;h2 id=&quot;privacy&quot;&gt;Privacy&lt;/h2&gt;

&lt;p&gt;The goal of Eki Live is to improve your ease of navigation around Tokyo. Eki Live does not and will never have ads or sell data to third-parties.&lt;/p&gt;

&lt;p&gt;Eki Live does not send any raw location data (i.e. latitude, longitude) off device (I have no interest in knowing where you are or where you’ve been).&lt;/p&gt;

&lt;p&gt;The only exception to the above is a technical detail due to an Apple API limitation, which I will explain below.&lt;/p&gt;

&lt;p&gt;In order for the app to start a Live Activity automatically while the app is not open, it must send data to Apple’s push notification server (APNS). APNS then forwards the data back to the device.&lt;/p&gt;

&lt;p&gt;When Eki Live is running in the background and determines which railway line you’re on, it sends (as an example) the following information to a twocentstudios.com server:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;{
  focusStationPhaseKey: &apos;focusStationPhase.upcoming&apos;,
  focusStation: &apos;Jiyugaoka&apos;,
  laterLaterStation: &apos;Gakugei-daigaku&apos;,
  laterStation: &apos;Toritsu-daigaku&apos;,
  railway: &apos;Toyoko Line&apos;,
  railwayDestinationStation: &apos;Shibuya&apos;,
  railwayHexColor: &apos;#DA0442&apos;,
  isReducedAccuracy: false
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The above information is packaged up without any parsing or logging and forwarded directly to APNS. It has only the information required to populate the first state of the Live Activity that will start on your device. The device address keys for APNS are generated anonymously by the operating system and are also not logged or associated with you in any way.&lt;/p&gt;

&lt;p&gt;Only the first set of data must be relayed through APNS in order to start the Live Activity. After the Live Activity successfully starts, it is updated directly on the device without any external server communication.&lt;/p&gt;

&lt;p&gt;I hope this superfluous server-based workaround for the ActivityKit API will be eliminated in the future.&lt;/p&gt;

&lt;p&gt;If for some reason you want to opt-out of any server-based communication:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Deny background Location Services permissions (please still permit “when in use” Location Services permissions)&lt;/li&gt;
  &lt;li&gt;Open the app manually each time you’d like to start tracking a journey.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All static data for stations and railways is included when downloading the app and is stored locally on your device. All location-based searching happens locally.&lt;/p&gt;

&lt;h2 id=&quot;battery-impact&quot;&gt;Battery impact&lt;/h2&gt;

&lt;p&gt;Eki Live is carefully designed to minimize battery impact.&lt;/p&gt;

&lt;p&gt;I’m acutely aware that iOS users are extremely protective of battery life, and apps that run in the background are rightfully treated with great suspect.&lt;/p&gt;

&lt;p&gt;Eki Live has two states:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;idle in the background&lt;/li&gt;
  &lt;li&gt;tracking a journey&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short, in my testing, Eki Live has the following battery impact:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;While idle&lt;/strong&gt; - an unmeasurably small amount of battery impact.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;While actively tracking a journey&lt;/strong&gt; - about the same battery impact as listening to a song on bluetooth headphones locally in the Music app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re looking for more detail, I’ll clarify exactly how Eki Live affects battery life below.&lt;/p&gt;

&lt;p&gt;While idle in the background, Eki Live uses zero processing power. It may or may not use some resident memory (an attribute managed by the operating system alongside all other backgrounded apps).&lt;/p&gt;

&lt;p&gt;When iOS detects your device has moved some hundreds of meters, it will wake up Eki Live for a few seconds to provide location data. Eki Live will quickly check whether your device is moving at train speeds. If it’s not, it goes right back to sleep.&lt;/p&gt;

&lt;p&gt;If the device &lt;em&gt;is&lt;/em&gt; moving, Eki Live switches into tracking mode.&lt;/p&gt;

&lt;p&gt;In tracking mode, Eki Live processes new GPS coordinates up to one coordinate per second while moving, and fewer while stopped at stations. Processing involves a few local SQLite database queries and some math operations. When the app is open, the map is updated as new GPS coordinates are received. When the app is closed, the app will only send updates when the contents of the Live Activity change (e.g. the station phase changes from “soon” to “now”).&lt;/p&gt;

&lt;p&gt;The processing work described above still has some room for optimizations, so future releases will include battery life improvements.&lt;/p&gt;

&lt;h2 id=&quot;eki-live-and-ambient-computing&quot;&gt;Eki Live and ambient computing&lt;/h2&gt;

&lt;p&gt;Eki Live is an experiment in &lt;a href=&quot;https://en.wikipedia.org/wiki/Ambient_intelligence&quot;&gt;ambient computing&lt;/a&gt;. Ambient computing is technology that blends into the environment and uses context to respond to human behavior without explicit commands. In the case of Eki Live, the app uses iPhone sensors to determine whether it’s on a train and integrate the sensor data with map data to determine which railway you’re on.&lt;/p&gt;

&lt;p&gt;My friend Sergio and I were at lunch talking about smartphones and apps several years ago. He said something that, at the time, didn’t totally make sense based on how limited iOS was outside of the app ecosystem. Paraphrasing him: “People don’t want to open apps. I should be able to get 90% of what I need from an app without opening it.”&lt;/p&gt;

&lt;p&gt;In the long early years of iOS, push notifications were the only way for an app to escape its bounds and integrate into other parts of the system UI. I was skeptical of Sergio’s sentiment at the time because it felt impossible to achieve. Push notifications are somewhat one-dimensional in what kind of user value you can deliver.&lt;/p&gt;

&lt;p&gt;In the more recent era of iOS, there are many more integration points for apps into the system UI: &lt;a href=&quot;https://support.apple.com/en-us/118610&quot;&gt;Widgets&lt;/a&gt;, &lt;a href=&quot;https://support.apple.com/guide/iphone/use-the-dynamic-island-iph28f50d10d/ios&quot;&gt;Live Activities&lt;/a&gt;, &lt;a href=&quot;https://developer.apple.com/app-clips/&quot;&gt;App Clips&lt;/a&gt;, &lt;a href=&quot;https://support.apple.com/guide/shortcuts/welcome/ios&quot;&gt;Shortcuts&lt;/a&gt;, &lt;a href=&quot;https://support.apple.com/en-us/118232&quot;&gt;Spotlight&lt;/a&gt;, etc. Each of these carves out a new niche and bridges the gap between app UI and system UI.&lt;/p&gt;

&lt;p&gt;Outside the iOS world, there’s been an influx of consumer hardware experiments that push ambient computing in some way: wearables like the defunct &lt;a href=&quot;https://en.wikipedia.org/wiki/Humane_Inc.&quot;&gt;Humane pin&lt;/a&gt;; voice assistants like Alexa speakers; and robot vacuums to name a few. Mixed reality wearables may eventually become ubiquitous and escape the app-centered philosophy still core to the OS of the Apple Vision Pro.&lt;/p&gt;

&lt;p&gt;All this is to say that Eki Live is my own skeptical experiment into escaping the app-bounds. As an app dev, the easiest delusion to fall into is “people want to open my app”. Eki Live is the first app I’ve tried to design around users getting value from the app without needing to open it or even remember it exists.&lt;/p&gt;

&lt;p&gt;The key phrase in that last sentence is “user value”. Showing users exactly what they want before they know they want it is very very hard. And to me, it’s annoying when it’s not right. For example, the “context aware” Widget Stacks built into iOS are hopeless. For all but the most obvious use cases it’s impossible to predict what people want to do with 100% accuracy. The only reason I started working on Eki Live was based on &lt;em&gt;probably&lt;/em&gt; having the ability to predict when someone was riding a train and then being able to present the UI automatically in a non-obtrusive way that still provided enough value.&lt;/p&gt;

&lt;p&gt;And, from a development perspective, it was not easy to connect all those dots. As of version 1.0, the dots are still somewhat loosely connected (as detailed in the Limitations section above):&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The app waking up in the background is limited by the accuracy of the Core Location APIs to quickly report significant location changes.&lt;/li&gt;
  &lt;li&gt;The app being able to accurately detect your current railway line is based on the accuracy of GPS and the density of other railways in the area.&lt;/li&gt;
  &lt;li&gt;The amount of information the app can show is limited to the space allotted to Live Activities on the lock screen and Dynamic Island.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If all these parts worked flawlessly, you would always have an indicator of the current/next station inconspicuously located in view. But what is the &lt;em&gt;value&lt;/em&gt; in that?&lt;/p&gt;

&lt;h2 id=&quot;why-do-train-passengers-need-to-see-the-currentnext-station&quot;&gt;Why do train passengers need to see the current/next station?&lt;/h2&gt;

&lt;p&gt;Put simply, when riding a train, you need to know what station you’re at so you can decide &lt;em&gt;whether or not to get off the train&lt;/em&gt;. By nature, the current/next station information is only relevant for a few minutes at a time, and thus your awareness of it as a rider needs to be continuously updated.&lt;/p&gt;

&lt;p&gt;Something I noticed during my alpha testing of Eki Live is that there’s a psychological aspect to &lt;em&gt;needing&lt;/em&gt; to know where you are at any given time. This might affect certain people more than others. Whether it’s a nagging feeling of “oh no, did I miss my stop?” or something deeper about being moved through space in a way that humans only began to experience in the last couple hundred years, I still feel it’s difficult to completely give up my awareness as a passenger in a train, car, etc. Although this phenomenon may be real and may drive retention, it’s probably not enough of a top-of-mind motivator for someone want to download the app.&lt;/p&gt;

&lt;p&gt;There are a few &lt;em&gt;train native&lt;/em&gt; ways to stay updated on the train’s current location or otherwise know when to get off the train (I’ll ignore app-based tools for the moment).&lt;/p&gt;

&lt;h3 id=&quot;door-adjacent-live-updating-signage&quot;&gt;Door-adjacent live-updating signage&lt;/h3&gt;

&lt;p&gt;Most train cars have a full color LCD or at least dot-matrix screen near the doors that shows the current or next station on the line:&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-jr-door-lcd-sign.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Next station information in an LCD screen above the door on a JR Line car&quot; title=&quot;Next station information in an LCD screen above the door on a JR Line car&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Next station information in an LCD screen above the door on a JR Line car&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;However:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The screen cycles through several different information sets like all stops, platform maps, delay information, etc., so the exact information you need may be unavailable at the moment you need it (when quickly deciding whether to exit the train before the doors close).&lt;/li&gt;
  &lt;li&gt;The screen cycles through ~4 languages/scripts, so it may be unreadable to you for short periods.&lt;/li&gt;
  &lt;li&gt;The screen may not be visible from all standing/sitting positions, especially on crowded trains.&lt;/li&gt;
  &lt;li&gt;The text on the screen may be too small to read from your position.&lt;/li&gt;
  &lt;li&gt;Dot-matrix screens are particularly low-information density and hard to parse.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;audio-announcements&quot;&gt;Audio announcements&lt;/h3&gt;

&lt;p&gt;Announcements for the train’s current location are made over the loudspeakers in each car. They usually include an announcement for:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The next station as soon as the doors close at the current station.&lt;/li&gt;
  &lt;li&gt;The approaching station within a couple hundred meters.&lt;/li&gt;
  &lt;li&gt;The current station as the doors are opening.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Depending on the line, these announcements are made consecutively in Japanese and English. They sometimes include a list of the connecting railway lines at a station, which side the doors will open on, a reminder of the rules of riding the train, or non-automated, urgent information about delays or accidents.&lt;/p&gt;

&lt;p&gt;Audio announcements are useful, but also have weak points:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Audio announcements are push-based not pull-based (i.e. not on-demand) – you need to pay attention at the right time or always be passively listening.&lt;/li&gt;
  &lt;li&gt;Audio announcements can be difficult to hear in noisy cars.&lt;/li&gt;
  &lt;li&gt;Audio announcements are inaudible when listening to music or spoken-word content on headphones.&lt;/li&gt;
  &lt;li&gt;Non-automated announcements (live from the conductor) can be especially difficult to understand.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;platform-signage&quot;&gt;Platform signage&lt;/h3&gt;

&lt;p&gt;When pulling into a station, the station’s name and its one or two adjacent stations are often written in various locations along the platform. Generally, whether or not you can see one of them as a rider is random depending on which train car you’re in, your relationship to a window facing the platform, and whether there are other passengers (on the train or waiting on the platform) blocking your view.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-bashamichi-photo.jpg&quot; width=&quot;&quot; height=&quot;200&quot; alt=&quot;Platform signage on the opposite wall of the platform at Bashamichi station&quot; title=&quot;Platform signage on the opposite wall of the platform at Bashamichi station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Platform signage on the opposite wall of the platform at Bashamichi station&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;timetable&quot;&gt;Timetable&lt;/h3&gt;

&lt;p&gt;If you somehow have access to the train’s timetable &lt;em&gt;and&lt;/em&gt; the train you’re riding stays true to that timetable through your arrival station, you could use your watch to know approximately when to get off the train.&lt;/p&gt;

&lt;p&gt;My other train-related app &lt;a href=&quot;/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright&lt;/a&gt; actually facilitates this method once you’ve selected your destination in the app. It shows your arrival time next to the current time as a Live Activity.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-arrival-time-dynamic-island.jpg&quot; width=&quot;&quot; height=&quot;200&quot; alt=&quot;Eki Bright&apos;s Live Activity showing a 15:11 arrival time at Naka-meguro station after setting up a DIY Route in the app&quot; title=&quot;Eki Bright&apos;s Live Activity showing a 15:11 arrival time at Naka-meguro station after setting up a DIY Route in the app&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Eki Bright&apos;s Live Activity showing a 15:11 arrival time at Naka-meguro station after setting up a DIY Route in the app&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;navigation-apps&quot;&gt;Navigation apps&lt;/h3&gt;

&lt;p&gt;Any number of popular navigation apps (Google Maps, Apple Maps, Jourdan Norikai Annai) will show you all kinds of information about your journey, both within the app and in other parts of the system via Live Activities, widgets, notifications, etc.&lt;/p&gt;

&lt;p&gt;However, the main downside to all of these apps is that you always need to explicitly tell the app that you’ve started a trip, where your destination is, and which specific departed train you’re riding. Or if you just want to see your current location on a map, you need to open the app and wait for the GPS to kick in.&lt;/p&gt;

&lt;p&gt;If you’re just taking a quick trip or you’re on a route you know well, it’s usually not worth the hassle to open a navigation app to search for and select your exact itinerary, especially if you’ve already departed.&lt;/p&gt;

&lt;h3 id=&quot;where-eki-live-shines&quot;&gt;Where Eki Live shines&lt;/h3&gt;

&lt;p&gt;Eki Live is especially useful for those everyday, routine rides. You don’t need to remember to open an app. You don’t need to bother selecting a route. It just appears when its relevant and disappears when it’s not. It adds another layer of security and awareness, especially when you’ve got your headphones in and are locked into reading an article or manga, watching a video, scrolling endlessly, or deep in a mobile game. It only needs a few pixels of otherwise unused screen real estate.&lt;/p&gt;

&lt;p&gt;Tourists may also find Eki Live useful, but I haven’t explored this use case fully. In theory, having another tool that automatically shows information about what railway line you’re riding could help tourists from getting lost. But especially with Eki Live’s current limitations on underground operation, thinking about another app may be a distraction.&lt;/p&gt;

&lt;h2 id=&quot;roadmap&quot;&gt;Roadmap&lt;/h2&gt;

&lt;p&gt;The first thing on the roadmap is to get a general feel for whether this idea connects with people:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Is the value-proposition presented in a way that makes people curious enough to download the app, set it up, and see it working as designed with their own eyes?&lt;/li&gt;
  &lt;li&gt;Do users start to rely on it enough to complain about its limitations?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This means marketing the app well enough to fill the top of the funnel.&lt;/p&gt;

&lt;p&gt;Assuming the above two points check out and the app concept is worth pursuing further, I’ll be working on two concurrent high-level goals:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Improve usability&lt;/strong&gt;: add more ways for users to customize the app if they so choose - snooze automatic tracking for some period of time; better support manually triggered tracking; add station arrival alarms; show more information in the app about the current railway; show estimated arrival times.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Improve the tracking algorithm&lt;/strong&gt;: improve the train alighting detection; improve the differentiation between parallel railways; underground railway detection.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I’d also like to incorporate some of the research done for Eki Live into Eki Bright as I originally intended. But that is lower priority until the concept of Eki Live is thoroughly proven or disproven.&lt;/p&gt;

&lt;h2 id=&quot;wrap-up&quot;&gt;Wrap up&lt;/h2&gt;

&lt;p&gt;I’m really looking forward to receiving feedback from everyone that tries out Eki Live. There are so many unique ways people get around Tokyo. Hopefully Eki Live can find its niche.&lt;/p&gt;
</description>
        <pubDate>Tue, 03 Jun 2025 07:27:00 -0500</pubDate>
        <link>https://twocentstudios.com/2025/06/03/eki-live-announcement/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/06/03/eki-live-announcement/</guid>
        
        <category>ekilive</category>
        
        <category>app</category>
        
        
      </item>
    
      <item>
        <title>Core Image Labo - Open Source iOS App for Core Image Experimentation</title>
        <description>&lt;p&gt;I wrote an iOS app called Core Image Labo for experimenting with &lt;a href=&quot;https://developer.apple.com/documentation/coreimage&quot;&gt;Core Image&lt;/a&gt; filters. It was a “weekend project” in service of a more fully-featured upcoming video-shooting app. I decided to clean it up and release on the App Store and as open source with an MIT license.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Open source on &lt;a href=&quot;https://github.com/twocentstudios/coreimagelab&quot;&gt;GitHub - Core Image Labo&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;Available on the &lt;a href=&quot;https://apps.apple.com/us/app/core-image-labo/id6742433427&quot;&gt;App Store - Core Image Labo&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/core-image-labo-marketing.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Marketing screenshots for Core Image Labo&apos;s v1.0 release&quot; title=&quot;Marketing screenshots for Core Image Labo&apos;s v1.0 release&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Marketing screenshots for Core Image Labo&apos;s v1.0 release&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You first set up a global input image (or use the default), and optionally a global background/target image (these are used for composite and transition filter types, respectively).&lt;/p&gt;

&lt;p&gt;Then you can add any number of CIFilters from the list of supported filters. I was most interested in filters with numerical inputs you could control via sliders, so that’s what I’ve implemented first.&lt;/p&gt;

&lt;p&gt;The other input types are slightly more complex (but very much reasonable) to model in UI like &lt;a href=&quot;https://developer.apple.com/documentation/coreimage/civector&quot;&gt;CIVector&lt;/a&gt; and &lt;a href=&quot;https://developer.apple.com/documentation/corefoundation/cgaffinetransform&quot;&gt;CGAffineTransform&lt;/a&gt;, and I don’t personally need to experiment with any of those filters at the moment, so I’ve held off on implementing support for them for v1.0.&lt;/p&gt;

&lt;p&gt;Finally, there are some simple tools for exporting the filtered image you see in the preview and a JSON file containing values for the filters used.&lt;/p&gt;

&lt;p&gt;I made an icon using Figma’s vector tools. Lately I’ve been using Blender to make icons in 3D, but I’ve been realizing that 3D-rendered images actually require some de-rendering to make them more illustrative and easier to read in the small pixel format of an app icon. For this side project, it was a lot faster to start from a 2D vector and render with simple shapes and color fills.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/core-image-labo-app-icon.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Core Image Labo&apos;s app icon created in Figma&quot; title=&quot;Core Image Labo&apos;s app icon created in Figma&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Core Image Labo&apos;s app icon created in Figma&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;There are already a few very robust tools for working with Core Image on macOS. Writing code helps me learn though, and it was nice to have my own sandbox to experiment with to (re)learn Core Image. I figured it might be useful to some other devs to have an open source base to work from in case they’re doing something unique that isn’t supported by the other commercial apps.&lt;/p&gt;

&lt;p&gt;If you’re a dev working with Core Image, give it a go and contribute a feature or a bug fix if you can.&lt;/p&gt;
</description>
        <pubDate>Tue, 25 Feb 2025 09:22:00 -0600</pubDate>
        <link>https://twocentstudios.com/2025/02/25/core-image-labo/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/02/25/core-image-labo/</guid>
        
        <category>coreimagelabo</category>
        
        <category>app</category>
        
        <category>ios</category>
        
        <category>apple</category>
        
        
      </item>
    
      <item>
        <title>8-bit Nails - Pixel Art Nail Diary</title>
        <description>&lt;p&gt;This week I released 8-bit Nails. It’s a light-hearted app for nail painting enthusiasts to express their creativity through pixel art and document their manicures.&lt;/p&gt;

&lt;p&gt;Download it &lt;a href=&quot;https://apps.apple.com/us/app/8-bit-nails/id6737764793&quot;&gt;on the App Store&lt;/a&gt;.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/8-bit-nails-marketing-images.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Marketing screenshots for 8-bit Nails on v1.0 release&quot; title=&quot;Marketing screenshots for 8-bit Nails on v1.0 release&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Marketing screenshots for 8-bit Nails on v1.0 release&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;After you or a loved one gets their nails done IRL, jump into 8-bit Nails and create a matching set of nails with your own vision in pixel art style. Manicures can be simple or elaborate, and using your creativity to translate them into pixel art style is a fun challenge.&lt;/p&gt;

&lt;p&gt;The drawing tools are simple, but there are a few built-in helpers to selectively copy an individual nail across to other nails. You can customize the nail on each hand.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/8-bit-nails-drawing-tools.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;8-bit Nails includes helper tools to eliminate the boring parts of painting&quot; title=&quot;8-bit Nails includes helper tools to eliminate the boring parts of painting&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;8-bit Nails includes helper tools to eliminate the boring parts of painting&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The system color picker is available, and the already used colors are easily accessible as a dynamic palette. And undo and redo functions are available for each nail.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/8-bit-nails-color-picker.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;The color picker&quot; title=&quot;The color picker&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The color picker&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;After you’ve finished pixel-fying your nails, they appear in the diary tagged with the current date. You can look back to see each of your nails over time.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/8-bit-nails-diary.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;The main screen shows a diary of your nails with most recent at the top&quot; title=&quot;The main screen shows a diary of your nails with most recent at the top&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The main screen shows a diary of your nails with most recent at the top&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;There’s also a full screen viewer when you want to show off your work in person.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/8-bit-nails-large-view.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;View your nails in full screen&quot; title=&quot;View your nails in full screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;View your nails in full screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And finally, there’s a special shareable image version with an auto-generated background color. Save this to your camera roll or send it to friends.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/8-bit-nails-shareable-image.jpg&quot; width=&quot;&quot; height=&quot;250&quot; alt=&quot;An example shareable image&quot; title=&quot;An example shareable image&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;An example shareable image&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;background&quot;&gt;Background&lt;/h2&gt;

&lt;p&gt;My girlfriend is a nail enthusiast. She gets her nails painted every couple of weeks. I liked seeing what new designs she had and a budding idea came to me of having a painting app to keep track of them. While prototyping, I realized having a full suite of drawing tools and brushes was way too complicated and intimidating. But arbitrarily limiting the drawing resolution created a fun constraint, made it easier to paint on a smartphone-sized touch screen, and ensured that there was a soft-limit on the time it takes to get to the finish line.&lt;/p&gt;

&lt;p&gt;I finished a prototype version over a weekend, got it on Test Flight, and sent her an invite before the holidays. Over time, I’ve slightly improved the tools, fixed a few bugs, and added a few nice-to-have helpers. It was fun to see how both her and I interpreted her nails differently in pixel art style. Only after a couple rounds, I think each of us has gotten better at translating the pixel art style. Some of the more complex 3D designs she’s gotten IRL have been especially fun to try to paint in the app.&lt;/p&gt;

&lt;p&gt;When I started developing the app, I wasn’t planning on taking it beyond something for the two of us. But as I chipped away at features and slowly noticed how popular manicures were with those around me, I decided it’d be worthwhile to put the finishing touches on the app and release it publicly on the App Store.&lt;/p&gt;

&lt;p&gt;There are already a slew of manicure-related apps on the App Store. All fall into the category of games, photos for inspiration, or hyper-realistic painting simulators. Most are targeted towards young girls.&lt;/p&gt;

&lt;p&gt;Similarly, there are plenty of pixel art apps. On the casual side, there are paint-by-numbers apps. On the tools side, there are full pixel art suites with layers and other complex tools.&lt;/p&gt;

&lt;p&gt;I’m curious to see whether a cross between the two categories will find an audience.&lt;/p&gt;

&lt;h2 id=&quot;development&quot;&gt;Development&lt;/h2&gt;

&lt;p&gt;On the technical side, I’m using SwiftUI and no external frameworks. Since my original goal was a personal app, the code reflects that.&lt;/p&gt;

&lt;p&gt;Data for all nail sets are saved to one file. The data is saved as matrix of color values and drawn live in a SwiftUI &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Canvas&lt;/code&gt; View. Currently, the canvas is hard-coded to 10x16 pixels, but the code supports any resolution.&lt;/p&gt;

&lt;p&gt;I use the system color picker. Undo/redo is implemented as a stack of nail data for each nail in the set. I wanted to experiment with some custom transitions so I didn’t use any &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sheet&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NavigationStack&lt;/code&gt; views this time; all views are layered in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ZStack&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the app starts to get traction, I’m planning on cleaning up the codebase. I realize with my last app Eki Bright I probably leaned a little too hard on the side of a clean codebase. If I want to continue on the indie dev path, I’ll need to keep optimizing for coding practices that facilitate a sustainable business, which means more up-front research, more marketing, more monetization, and more failing fast. All things that spending excess time in the codebase takes away from.&lt;/p&gt;

&lt;p&gt;I think it’d be fun to add a widget that shows your latest nails on your home screen. And allow customization of the nail shape. Besides that, I’m going to wait to see what real users are looking for.&lt;/p&gt;
</description>
        <pubDate>Mon, 17 Feb 2025 06:22:00 -0600</pubDate>
        <link>https://twocentstudios.com/2025/02/17/8-bit-nails-pixel-art-nail-diary/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/02/17/8-bit-nails-pixel-art-nail-diary/</guid>
        
        <category>8bitnails</category>
        
        <category>app</category>
        
        
      </item>
    
      <item>
        <title>Eki Bright - Tokyo Area Train Timetables</title>
        <description>&lt;p&gt;My latest app is called Eki Bright or 駅ブライト in Japanese.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-app-icon.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;Eki Bright app icon&quot; title=&quot;Eki Bright app icon&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Eki Bright app icon&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In short, it’s an app for quickly accessing offline station timetables and train timetables for railways in the Tokyo metropolitan area including Kanagawa, Chiba, and Saitama prefectures.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-station-timetable.jpg&quot; width=&quot;&quot; height=&quot;650&quot; alt=&quot;The station timetable screen for Bashamichi station on the Minatomirai Line&quot; title=&quot;The station timetable screen for Bashamichi station on the Minatomirai Line&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The station timetable screen for Bashamichi station on the Minatomirai Line&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In this series of posts I’ll talk about:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;This post&lt;/strong&gt; - the motivation behind the app and the solution I’ve begun to explore&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;/2024/08/06/eki-bright-developing-the-app-for-ios/&quot;&gt;Eki Bright- Developing the App for iOS&lt;/a&gt;&lt;/strong&gt; - the high-level implementation details&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tokyo-area residents can download the app from the &lt;a href=&quot;https://apps.apple.com/app/%E9%A7%85%E3%83%96%E3%83%A9%E3%82%A4%E3%83%88/id6504702463&quot;&gt;App Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Eki Bright is currently closed source due to data licensing issues, but I’d like to open source it as some point once I get that aspect figured out.&lt;/p&gt;

&lt;h2 id=&quot;background&quot;&gt;Background&lt;/h2&gt;

&lt;p&gt;I’m a public transit (specifically train) fan. And Tokyo is easily one of the best cities in the world to get around by train.&lt;/p&gt;

&lt;p&gt;Due to the large market, there are already plenty of full featured transit apps – &lt;a href=&quot;https://maps.google.com/&quot;&gt;Google Maps&lt;/a&gt;, &lt;a href=&quot;https://www.apple.com/maps/&quot;&gt;Apple Maps&lt;/a&gt;, &lt;a href=&quot;https://www.navitime.co.jp/&quot;&gt;Navitime&lt;/a&gt;, &lt;a href=&quot;https://www.jorudan.co.jp/&quot;&gt;Jorudan&lt;/a&gt;, etc. And I’m a heavy user of all of them, especially Google Maps.&lt;/p&gt;

&lt;p&gt;However, after many years of use, I started to find some pain points.&lt;/p&gt;

&lt;p&gt;I got to know my daily trips well enough that I no longer needed to know all of the below information for each trip:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Which station I needed to walk to&lt;/li&gt;
  &lt;li&gt;How long it would take to walk to the station&lt;/li&gt;
  &lt;li&gt;Which railway I needed to use&lt;/li&gt;
  &lt;li&gt;Which platform I needed to wait at&lt;/li&gt;
  &lt;li&gt;Which train type I needed to get on&lt;/li&gt;
  &lt;li&gt;When the target train type would depart&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I’d memorized 1-5, and only needed the departure times in order to optimize my trip.&lt;/p&gt;

&lt;p&gt;All of the other information requires opening Google Maps, waiting for it to load, entering a destination, occasionally entering an origin, waiting for the route data to load, scrolling, choosing the top result, scrolling.&lt;/p&gt;

&lt;p&gt;Then there were the times that I’d want to check departure times assuming I’d leave in half an hour. In that case I’d also have to manually enter a departure time or manually change the train in the helpful selection dropdown on the route page (surprisingly, a feature still not available on the web app).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-gmaps-other-departures.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Adjusting the exact train within a chosen route is an essential step in using Google Maps&apos; routing&quot; title=&quot;Adjusting the exact train within a chosen route is an essential step in using Google Maps&apos; routing&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Adjusting the exact train within a chosen route is an essential step in using Google Maps&apos; routing&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;There were also times where I knew more than Google Maps. For example, I know that on weekends, I can sometimes take a local or express train from Bashamichi station to Minatomirai station to catch a limited express train. This little maneuver can save 10-15 minutes into Shibuya. But this information is not easy to access with the Google Maps interface.&lt;/p&gt;

&lt;p&gt;Sure, for the most common situation I could head out from my apartment at whatever time was natural then wait on the platform for however long. But if I could see the next departures board at the station before I left my apartment, I could optimize my time better.&lt;/p&gt;

&lt;p&gt;Thus, the idea of Eki Bright was born.&lt;/p&gt;

&lt;h2 id=&quot;goals&quot;&gt;Goals&lt;/h2&gt;

&lt;p&gt;My north star for this app is, like I mentioned above, to have the same information I’d have while standing on the platform looking at the next departures board.&lt;/p&gt;

&lt;p&gt;The main principle required for that use case is simple: &lt;strong&gt;speed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every feature is evaluated against getting the amount of time it takes to see the departure timetable for a station optimized to near zero.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Offline data: no server roundtrip is required to fetch a station timetable, saving seconds on each tap, especially in slow network environments often expected when outside or underground. (The only reason this is even feasible is because trains in Japan are so rarely late.)&lt;/li&gt;
  &lt;li&gt;Bookmarked stations: the vast majority of trips are taken from the same stations: near home or work or a third place. Letting the user choose these and putting them on the home screen eliminates a step in getting them the information they need.&lt;/li&gt;
  &lt;li&gt;Nearby stations: for the next minority of trips, the user wants to see timetables for stations nearby their current location. This is trivial with location services (GPS).&lt;/li&gt;
  &lt;li&gt;Search: as a catch all for the remaining trips, stations can be found by text search (in romaji, hiragana, katakana, or kanji).&lt;/li&gt;
  &lt;li&gt;Widgets: for bookmarked stations, users can see timetables without even opening the app. The home screen or lock screen makes it trivial to see departure times at a glance.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-home.jpg&quot; width=&quot;&quot; height=&quot;650&quot; alt=&quot;Nearby and bookmarked stations on the app home screen&quot; title=&quot;Nearby and bookmarked stations on the app home screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Nearby and bookmarked stations on the app home screen&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-widgets.jpg&quot; width=&quot;&quot; height=&quot;650&quot; alt=&quot;Widgets make station timetables available at a glance&quot; title=&quot;Widgets make station timetables available at a glance&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Widgets make station timetables available at a glance&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My anti-goals – things I specifically did not want to include for fear of overcomplicating the interface and muddying the value proposition – are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Routing (i.e. inputting an origin and destination and calculating which trains to use)&lt;/li&gt;
  &lt;li&gt;Covering every city in Japan (or anywhere else in the world)&lt;/li&gt;
  &lt;li&gt;Covering other modes of public transit (e.g. buses)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Features I feel could potentially fit into the ethos of the app, but aren’t a high-priority:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Showing any information on a map&lt;/li&gt;
  &lt;li&gt;Live-updating departure times&lt;/li&gt;
  &lt;li&gt;Platform number information&lt;/li&gt;
  &lt;li&gt;Stair/elevator locations on the platform in relation to a train car&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One feature that wasn’t 100% in line with my north star but turned out to be so useful as to be prioritized for first release: train timetables. Tapping a departure time in the station timetable shows the full path of that particular train as a train timetable. This is useful for two reasons:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Seeing your estimated arrival time at any destination on that line.&lt;/li&gt;
  &lt;li&gt;Seeing which stations that train stops at, in the case you’re not totally familiar with the local, express, etc. designations for that railway.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-train-timetable.jpg&quot; width=&quot;&quot; height=&quot;650&quot; alt=&quot;train timetables show which stations a particular train will stop at, and at what times&quot; title=&quot;train timetables show which stations a particular train will stop at, and at what times&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;train timetables show which stations a particular train will stop at, and at what times&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I have many more ideas on how I can make this data alone even more useful to users without further complicating the app.&lt;/p&gt;

&lt;h2 id=&quot;one-example-of-ux-optimization-for-speed&quot;&gt;One example of UX optimization for speed&lt;/h2&gt;

&lt;p&gt;A conceptually-murky yet concretely defined &lt;em&gt;station&lt;/em&gt; in the app usually serves two directions unless it is a terminus.&lt;/p&gt;

&lt;p&gt;I made a decision early on for a &lt;em&gt;bookmark&lt;/em&gt; to represent the combination of a unique &lt;em&gt;station&lt;/em&gt; and a &lt;em&gt;direction&lt;/em&gt;. The reasoning being that it’s quite common for a passenger to only ride one direction from a particular station. For example, from my local station Bashamichi, I’m always going &lt;em&gt;inbound&lt;/em&gt; into Tokyo from my house and never &lt;em&gt;outbound&lt;/em&gt; back towards Motomachi.&lt;/p&gt;

&lt;p&gt;It would significantly slow me down if every time I had to select “inbound” or “outbound” each time I wanted to view the timetable for Bashamichi station.&lt;/p&gt;

&lt;p&gt;As the designer, I could put both timetables on one screen, however that would crowd the screen with useless (to me the user in my particular scenario) information.&lt;/p&gt;

&lt;p&gt;In the case where I do use both directions somewhat equally, it’s perfectly reasonable to have two bookmarks for the same station.&lt;/p&gt;

&lt;p&gt;I consider this well designed for the bookmarks use case. But for nearby stations and text search results users still need to select which direction they’re going before the app can display the correct timetable.&lt;/p&gt;

&lt;p&gt;My original design presented a station detail screen in this case. It showed no timetable data yet. The user had to select one of two (or one) direction first in order to view the timetable.&lt;/p&gt;

&lt;p&gt;This configuration almost immediately felt wrong during use. On one hand, users were less likely to make a mistake and misread the timetable for the opposite direction. On the other hand, it felt so slow having an intermediate step presented in order to see the timetable.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-station-detail.jpg&quot; width=&quot;&quot; height=&quot;650&quot; alt=&quot;The deprecated Station Detail screen, looking alright but adding time and cognitive overhead&quot; title=&quot;The deprecated Station Detail screen, looking alright but adding time and cognitive overhead&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The deprecated Station Detail screen, looking alright but adding time and cognitive overhead&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;For the first release, I made two revisions to the UX:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;I removed the station detail screen. After selecting a search result, the user would now be dropped directly into the timetable for the default direction for that station.&lt;/li&gt;
  &lt;li&gt;I added a quick swipe between that station’s direction’s timetables. Now users could quickly flip between the two directions with conceptually fewer steps.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-station-timetable-swipe.gif&quot; width=&quot;&quot; height=&quot;650&quot; alt=&quot;Swipe between directions of a station or tap the segmented control at the bottom&quot; title=&quot;Swipe between directions of a station or tap the segmented control at the bottom&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Swipe between directions of a station or tap the segmented control at the bottom&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Again, optimizing for speed.&lt;/p&gt;

&lt;p&gt;I could go further and silently save which direction for any given station the user last viewed. I could also redesign the station detail screen to be more useful to all use cases, whether that be quickly viewing either timetable or just getting metadata about the station of interest. Either way, I don’t want to compromise on the speed of getting a user to the information they’re looking for.&lt;/p&gt;

&lt;h2 id=&quot;data&quot;&gt;Data&lt;/h2&gt;

&lt;p&gt;In my prototyping phase I did a dive into potential data sources, both online and offline, paid and free.&lt;/p&gt;

&lt;p&gt;In the current self-funding phase of my business, I heavily preferred not paying for data. This keeps my indefinite overhead low and means I have more flexibility in pricing in an already crowded and mature market.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://developer.odpt.org/&quot;&gt;Association for Open Data of Public Transportation&lt;/a&gt; aggregates and publishes data for transit systems across Japan. They also run a hackathon/contest for websites and apps built with their sourced data. This seemed like a reliable enough source for the data at the foundation of the app.&lt;/p&gt;

&lt;h2 id=&quot;prototyping&quot;&gt;Prototyping&lt;/h2&gt;

&lt;p&gt;I hacked together a widget that ingested the aggregated timetables for Bashamichi station (my most used station) on a weekday and showing them in a widget.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-widget-proto.gif&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;One of the first widgets I hacked together&quot; title=&quot;One of the first widgets I hacked together&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;One of the first widgets I hacked together&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The goal at this point was to evaluate how well this implementation solved my hypothetical pain point. I used it for a couple weeks and, yes, I did feel it was a pretty useful supplement to my go-to Google Maps. I could swipe over on my home screen on my way out the door and see whether I need to hustle out to make the next limited express or whether I could take my time.&lt;/p&gt;

&lt;h2 id=&quot;productionizing&quot;&gt;Productionizing&lt;/h2&gt;

&lt;p&gt;At this point I had to decide whether to leave this as a &lt;em&gt;scratch-my-own-itch&lt;/em&gt; project, or whether it had potential as a full featured app I could offer of the App Store and support indefinitely. This isn’t an easy decision!&lt;/p&gt;

&lt;p&gt;In the end, at my level of business knowledge and sophistication, I made this decision from a selfish perspective:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;How much did I personally want to use the most ideal version of this app?&lt;/li&gt;
  &lt;li&gt;How interested were my friends in the concept?&lt;/li&gt;
  &lt;li&gt;How motivated was I to work on this concept for weeks or months? Could I get it to the finish line?&lt;/li&gt;
  &lt;li&gt;How capable was I of doing the design on my own?&lt;/li&gt;
  &lt;li&gt;How much revenue did I need from the app to make it worthwhile?&lt;/li&gt;
  &lt;li&gt;How many monetizable features could I implement while still committing to simplicity and speed?&lt;/li&gt;
  &lt;li&gt;What other ideas was I more motivated to work on instead?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;making-sense-of-the-data&quot;&gt;Making sense of the data&lt;/h2&gt;

&lt;p&gt;I was by no means an expert on the intricacies of the vast Tokyo area train network. I’ve done my fair share of rides in the 6 years I’ve lived here, but vast majority of those rides were the same trains going to the same stations, with extracurricular trips being a novelty I quickly forgot the details of.&lt;/p&gt;

&lt;p&gt;Learning not only the train system as it exists in the real world, but also as it exists modeled in my chosen dataset was a process. A couple fun concepts I had to wrap my head around:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;How to model time, especially across midnight and logical day spans&lt;/li&gt;
  &lt;li&gt;How to choose a weekday, weekend, Saturday, or holiday schedule&lt;/li&gt;
  &lt;li&gt;How to internally and externally deal with train directions and destinations&lt;/li&gt;
  &lt;li&gt;How to deal with the one-off case of the one circle line (Yamanote)&lt;/li&gt;
  &lt;li&gt;How all these concepts were most naturally expressed in Japanese&lt;/li&gt;
  &lt;li&gt;How to deal with searching via multiple Japanese character sets&lt;/li&gt;
  &lt;li&gt;How to generalize train types like local, express, etc. over disparate railways&lt;/li&gt;
  &lt;li&gt;How to display colors and associate them with railway concepts&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-db.png&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;A peek into the SQLite database that powers Eki Bright&quot; title=&quot;A peek into the SQLite database that powers Eki Bright&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A peek into the SQLite database that powers Eki Bright&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;internationalization&quot;&gt;Internationalization&lt;/h2&gt;

&lt;p&gt;My initial thought was that it’d be reasonable support both English and Japanese localizations at first release. Especially considering my dataset has pretty good English support.&lt;/p&gt;

&lt;p&gt;However, after getting into the details of the design, I realized it’d be a lot more pragmatic to optimize the interface for Japanese at first. Although I’m keen to help the low Japanese-proficiency community in Tokyo, realistically, the vast majority of my users will be comfortable enough with Japanese.&lt;/p&gt;

&lt;p&gt;In the next phase I’ll focus on getting internationalization complete on the interface layer of the app, then focus on the data layer. It will require some design thought because a lot of the time even English speakers will want access to both the English and Japanese station name, train type, etc. when out and looking for their train.&lt;/p&gt;

&lt;h2 id=&quot;icon&quot;&gt;Icon&lt;/h2&gt;

&lt;p&gt;Creating the app icon was the last step for Eki Bright. My intention for much of development was to create an anthropomorphized train character with a happy disposition, similar to the &lt;a href=&quot;/2023/10/30/count-biki-app-and-character-design/&quot;&gt;vampire rabbit mascot&lt;/a&gt; Count Biki.&lt;/p&gt;

&lt;p&gt;Step zero was buying a magazine with lots of various train photos.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-train-magazine.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Luckily, train enthusiast magazines are not difficult to find in Japan&quot; title=&quot;Luckily, train enthusiast magazines are not difficult to find in Japan&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Luckily, train enthusiast magazines are not difficult to find in Japan&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Step one was sketching some trains, then iterating heavily on character designs.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-icon-sketchbook.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;My somewhat embarrassing sketchbook (blue pen/good sketches are by my friend Kazuyo)&quot; title=&quot;My somewhat embarrassing sketchbook (blue pen/good sketches are by my friend Kazuyo)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;My somewhat embarrassing sketchbook (blue pen/good sketches are by my friend Kazuyo)&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Step two was 3D modeling.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-icon-wip.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;3D design work in progress&quot; title=&quot;3D design work in progress&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;3D design work in progress&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Which unfortunately ended up with me throwing in the towel regarding the character and pivoting into a more traditional icon.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-app-icon.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The final icon&quot; title=&quot;The final icon&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The final icon&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Although a cute character would be great, I realized it wasn’t a huge loss. Realistically, the app design is extremely utilitarian. A cute character on the book cover wouldn’t really reflect the contents very well.&lt;/p&gt;

&lt;p&gt;In the future, assuming a healthy adoption of the app, I’d love to do a full redesign that emphasizes playfulness alongside usefulness and a streamlined UX. At that point, I think it’d be reasonable to revisit the app icon design.&lt;/p&gt;

&lt;h2 id=&quot;monetization&quot;&gt;Monetization&lt;/h2&gt;

&lt;p&gt;Monetization on the App Store is still a mystery box to me. The entire market is relatively mature closing in on two decades on existence.&lt;/p&gt;

&lt;p&gt;At first release, I decided not to implement any in-app purchase or subscription at all. My focus up front is testing the market and ironing out the rough edges.&lt;/p&gt;

&lt;p&gt;I think there are 3 valid monetization paths (or some combination):&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Themes: offer themes for the app and/or widgets that are each unlockable as a one-time purchase.&lt;/li&gt;
  &lt;li&gt;Subscription: make the app functionality severely limited outside a subscription: no nearby stations, no train timetables, only one bookmark/widget. Charge a low monthly/yearly rate to cover the ongoing development costs and data renewal costs.&lt;/li&gt;
  &lt;li&gt;One-time purchase: assume prospective users understand the value, and that I can cover my long-term costs by adding new users. The upside is pricing simplicity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One reason I’m not focused on monetization yet is that I’m not confident I can fill the top of the funnel.&lt;/p&gt;

&lt;p&gt;Getting anyone’s attention is often an insurmountable problem, especially when most people already have an okay-ish solution to the problem I’m trying to solve.&lt;/p&gt;

&lt;p&gt;Does anyone have space on their phone for another widget or app? Do they want to build new muscle memory and decide to open app A or app B depending on the situation? Is it a daily-use app that justifies an ongoing payment?&lt;/p&gt;

&lt;p&gt;Although I implemented tips in raw Store Kit for my last app &lt;a href=&quot;/2023/10/29/count-biki-japanese-numbers/&quot;&gt;Count Biki&lt;/a&gt;, and it’ll theoretically be less development this time around, it still requires enough work that could be better spent on marketing, especially while the top of the funnel is so small.&lt;/p&gt;

&lt;p&gt;All that’s to say that for now I’m going to focus on finding a reliable process to get users into the top of the funnel (the ever-reliable VC startup playbook, haha).&lt;/p&gt;

&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;

&lt;p&gt;I’m of course happy to see this little app make its way out into the world, and hopefully shave of a few seconds or minutes for train-hopping Tokyo residents. I’m a little biased, but I’ve found myself reaching for Eki Bright before Google Maps for a majority of my trips, even when multiple transfers are involved.&lt;/p&gt;

&lt;p&gt;It may simply be a naive sense of control Eki Bright gives me when bouncing around timetables, but regardless it’s surprisingly entertaining (rather than burdensome) to do my own simple route planning on the fly. And knowing I’m getting the absolute fastest trip from A to B.&lt;/p&gt;

&lt;p&gt;For developers, see you in the &lt;a href=&quot;/2024/08/06/eki-bright-developing-the-app-for-ios/&quot;&gt;next post&lt;/a&gt; for all the fun development details.&lt;/p&gt;
</description>
        <pubDate>Sat, 27 Jul 2024 09:02:00 -0500</pubDate>
        <link>https://twocentstudios.com/2024/07/27/eki-bright-tokyo-area-train-timetables/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2024/07/27/eki-bright-tokyo-area-train-timetables/</guid>
        
        <category>ekibright</category>
        
        <category>app</category>
        
        
      </item>
    
  </channel>
</rss>
