<?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/ekibright/index.html</link>
    <atom:link href="https://twocentstudios.com/blog/tags/ekibright/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>Train Tracker Devlog 02</title>
        <description>&lt;p&gt;It’s been about 6 weeks since the last &lt;a href=&quot;/2025/04/15/train-tracker-checkpoint-devlog/&quot;&gt;train tracker devlog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I’ve been making an iOS app that automatically detects what train you’re riding (in Tokyo) and shows the current/next station in a Live Activity, all without needing to open the app.&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;250&quot; alt=&quot;Eki Live&apos;s Live Activity on the lock screen and Dynamic Island&quot; title=&quot;Eki Live&apos;s Live Activity on the lock screen and Dynamic Island&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Eki Live&apos;s Live Activity on the lock screen and Dynamic Island&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I’m finally on the cusp of the release of version 1.0 of Eki Live, the christened name of the previous working title &lt;em&gt;Train Tracker&lt;/em&gt;. At the end of the last post, I detailed what I thought was next on my TODO list.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;ul&gt;
    &lt;li&gt;Improve the algorithm to determine the correct railway faster, handle transfers, and off-board seamlessly.&lt;/li&gt;
    &lt;li&gt;Improve the design of the Live Activity.&lt;/li&gt;
    &lt;li&gt;Remove the debug screens and rework the in-app UI for onboarding, settings, and simple monitoring.&lt;/li&gt;
    &lt;li&gt;Create branding and add all the required info for the App Store.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;I did indeed finish ~all of~ most of these TODOs, but like usual it took a lot longer than I expected. Let’s start with the easy stuff first.&lt;/p&gt;

&lt;h3 id=&quot;home-ui&quot;&gt;Home UI&lt;/h3&gt;

&lt;p&gt;The UI for Eki Live is nowhere near as expansive as humble &lt;a href=&quot;https://twocentstudios.com/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright&lt;/a&gt;, which has a screen for each resource like station, timetable, railway, bookmarks, nearby station, search, etc. Eki Live is really just one screen, and in the ethos of the app, most users will rarely see it; the app is intended to function as an automatically appearing and disappearing Live Activity.&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;450&quot; alt=&quot;Eki Live&apos;s home screen, en route to Jiyugaoka station&quot; title=&quot;Eki Live&apos;s home screen, en route to Jiyugaoka station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Eki Live&apos;s home screen, en route to Jiyugaoka station&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;However, there are a few required visuals and functions:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The current railway, direction, focus station, and later stations: a larger, more detailed reflection of the data shown in the Live Activity.&lt;/li&gt;
  &lt;li&gt;A map with the user’s current location: a confirmation for users that they are where they think they are.&lt;/li&gt;
  &lt;li&gt;A list of other selectable railway candidates: in the dense railway environment of Tokyo, more often than not there are railways that run parallel for stretches of track. Eki Live defaults to the top ranked candidate based on an algorithmic score that improves with more data, but I wanted to give users the ability to override or lock-in the top candidate at will.&lt;/li&gt;
  &lt;li&gt;Menu: there are a few functions I wanted to include even if they are rarely used. A permissions checkup screen since Location Services permissions are imperative. A way to reset the algorithm in case it encounters a situation I can’t handle automatically yet. Eventually some other options too like a snooze button or list of alerts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My previous custom debug screens became outdated and I integrated the debug visuals into a “show stats” option that anyone can enable.&lt;/p&gt;

&lt;video src=&quot;/images/eki-live-v1-home-stats-en.mov&quot; controls=&quot;&quot; height=&quot;400&quot;&gt;&lt;/video&gt;

&lt;p&gt;I fell into the trap of over-optimizing the UI because honestly it’s one of my favorite parts of iOS development. I had to keep reminding myself that most users wouldn’t and &lt;em&gt;shouldn’t&lt;/em&gt; see this home screen if I had done the rest of my job properly.&lt;/p&gt;

&lt;h3 id=&quot;onboarding&quot;&gt;Onboarding&lt;/h3&gt;

&lt;p&gt;Eki Live doesn’t work like a normal app, so I spent more time than I usually do on an onboarding flow when the user opens the app for the first time.&lt;/p&gt;

&lt;p&gt;I’m not sure whether I’ve struck the right balance of over-explaining vs. under-explaining, perhaps the former. The main concern was that I need the user to understand the value proposition of the app and &lt;em&gt;why&lt;/em&gt; I need such intrusive Location Services permissions. Otherwise, they won’t allow background permissions, the app won’t start up, and they will forget about it and go on with their life.&lt;/p&gt;

&lt;video src=&quot;/images/eki-live-v1-onboarding-en.mov&quot; preload=&quot;none&quot; poster=&quot;/images/eki-live-v1-onboarding-en.png&quot; controls=&quot;&quot; height=&quot;400&quot;&gt;&lt;/video&gt;

&lt;h3 id=&quot;english-support&quot;&gt;English support&lt;/h3&gt;

&lt;p&gt;I was very on the fence about supporting English for the version 1 release, or really at all. Sure, I have English in the underlying station data, but I never got around to fully supporting English in Eki Bright because there just too many screens and too many layout edge cases to deal with for a non-target audience.&lt;/p&gt;

&lt;p&gt;For Eki Live, however, I decided that since my UI footprint was low and some of my beta testers preferred English, I would take a day to do a spike and see how much work it would add.&lt;/p&gt;

&lt;p&gt;There were a few tough points (Info.plist strings, lots of onboarding string, app extension strings), but the main breakthrough was simply using the compressed width system font. This got the width of the romaji station names down to near the width of the kanji versions. The amount of layout tweaks was minimal.&lt;/p&gt;

&lt;p&gt;I think English support makes more sense in Eki Live because in theory I could target overseas tourists as potential users. By design, Eki Bright doesn’t make sense as an app for tourists.&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-ja.png&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Eki Live&apos;s home screen in English and Japanese localizations&quot; title=&quot;Eki Live&apos;s home screen in English and Japanese localizations&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Eki Live&apos;s home screen in English and Japanese localizations&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;algorithm-improvements&quot;&gt;Algorithm improvements&lt;/h3&gt;

&lt;p&gt;After finishing all the above essentials for app release, I pushed a Test Flight v0.1, send it out to some beta testers, and then went out and took a few train rides.&lt;/p&gt;

&lt;p&gt;It was still a little exciting when, after riding about a stop and a half, the Live Activity would suddenly pop up showing the next station. However, with my critic hat on, I was becoming less bullish:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;It didn’t feel &lt;em&gt;magical&lt;/em&gt; having to wait so long for Eki Live to finally conclude I was on a train and appear in my Dynamic Island, especially for short rides.&lt;/li&gt;
  &lt;li&gt;It didn’t feel &lt;em&gt;magical&lt;/em&gt; that the app couldn’t differentiate between the Yamanote Line and the Keihin-Tohoku Line, even after they’d split at Shinagawa.&lt;/li&gt;
  &lt;li&gt;It didn’t feel &lt;em&gt;magical&lt;/em&gt; that the app couldn’t differentiate between the local-like Keihin-Tohoku Line and the express-like Tokaido Line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was at a crossroads in early May: do I release the app as-is or do I improve the train tracking algorithm?&lt;/p&gt;

&lt;p&gt;At first, it wasn’t a decision I actively made. While I waited for Test Flight review I wanted to do a spike to get a feel for how much work it would take to improve the algorithm.&lt;/p&gt;

&lt;p&gt;At that time I was using only station locations as my main data source for determining which railway and direction the user was riding. In one way this was a strength because it meant I could more easily expand the scope of the app in the future to support all of Japan or even other countries.&lt;/p&gt;

&lt;p&gt;Using only station locations was a weakness for accuracy and immediacy though. Although it’s much more difficult to obtain and maintain, having the full outline of the geopoints that make up a railway as it traverses between stations would in theory enable boosts in both detection accuracy and immediacy.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-railway-vs-station-coords.jpg&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;Station geopoints (large yellow dots) vs. railway geopoints (small white dots) for the Tsurumi line&quot; title=&quot;Station geopoints (large yellow dots) vs. railway geopoints (small white dots) for the Tsurumi line&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Station geopoints (large yellow dots) vs. railway geopoints (small white dots) for the Tsurumi line&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The easiest way to understand the limitations of using only station data is by looking at the eastern railway corridor between around Yohohama station and Tsurumi station:&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-mini-tokyo-3d-east-corridor.jpg&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;Parallel railways in the south eastern corridor from Yokohama to Tsurumi station as shown on minitokyo3d.com&quot; title=&quot;Parallel railways in the south eastern corridor from Yokohama to Tsurumi station as shown on minitokyo3d.com&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Parallel railways in the south eastern corridor from Yokohama to Tsurumi station as shown on minitokyo3d.com&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;For this stretch, there are 8 or so railways that run parallel for some portion before branching off. The Tokaido is the most express, only stopping at Yokohama and Kawasaki (further north of Tsurumi). Without supplementing the station data with additional data showing the relationships between the stations and railways, it would be impossible to determine the Tokaido was even in the list of candidates for trains the user is aboard.&lt;/p&gt;

&lt;p&gt;I realized that having the railway geopoint data wouldn’t solve &lt;em&gt;all&lt;/em&gt; the problems with the algorithm, but it &lt;em&gt;could&lt;/em&gt; raise the limit of possibility for speed and accuracy of the algorithm.&lt;/p&gt;

&lt;p&gt;The problem was that I didn’t immediately have a source of data for all railway geopoints. I started looking for options.&lt;/p&gt;

&lt;p&gt;The obvious first attempt was using the railway geopoint data included in the existing dataset I was using. However, this data was optimized for another use case and after a couple hours of combing through a multi-megabyte JSON file, I was stumped at how to parse it into the simple format I needed: ordered geopoints associated by railway.&lt;/p&gt;

&lt;p&gt;The most promising second option was Open Street Maps (OSM). It checked a lot of boxes: the data was free, continuously updated, open to updating from anyone, included all data in Japan and much of the rest of the world, included stations, railways, and railway geopoints, and had a robust query language and tooling.&lt;/p&gt;

&lt;p&gt;I did a spike and got pretty far in transforming the data into the format I needed from OSM. I spent days writing custom fetch queries and building tooling to evaluate the results.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-osm-overpass-query.png&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;A hard-won query to Open Street Maps to fetch all station and railway data in Japan&quot; title=&quot;A hard-won query to Open Street Maps to fetch all station and railway data in Japan&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A hard-won query to Open Street Maps to fetch all station and railway data in Japan&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I built a SwiftUI Preview to show random railways in the parsed OSM database to help me spot check the data:&lt;/p&gt;

&lt;video src=&quot;/images/eki-live-osm-random-railway-viewer.mov&quot; preload=&quot;none&quot; poster=&quot;/images/eki-live-osm-random-railway-viewer.jpg&quot; controls=&quot;&quot; height=&quot;450&quot;&gt;&lt;/video&gt;

&lt;p&gt;In the end, the data was just too raw for my use case. Station and railway names and colors were completely non-standardized across the dataset. The accuracy of railway geopoints would have to be crosschecked one-by-one. I realized it would take weeks or months of manual work to get to the point where the data could be trusted enough to rebuild as the foundation of the app.&lt;/p&gt;

&lt;p&gt;And I hadn’t even started rewriting the algorithm yet.&lt;/p&gt;

&lt;p&gt;I was about to give up when I made one last attempt at parsing the data from my original source. With some LLM help, I finally cracked the parser and with a few revisions and some more custom tooling, I finally had a reliable source of railway geopoint data to use in the algorithm.&lt;/p&gt;

&lt;video src=&quot;/images/eki-live-railway-viewer-app.mov&quot; preload=&quot;none&quot; poster=&quot;/images/eki-live-railway-viewer-app.jpg&quot; controls=&quot;&quot; height=&quot;450&quot;&gt;&lt;/video&gt;

&lt;p&gt;My idea for the new algorithm was to expand on the scoring system I had started before. My hypothesis was that by combining all the data I had into a score at a single point in time, then weighting those scores over time, I could manage the complexity and use real data to tweak the scoring system to continuously improve it.&lt;/p&gt;

&lt;p&gt;In reality, I still don’t feel confident I have a handle on the complexity yet.&lt;/p&gt;

&lt;p&gt;On a positive note, by this point I’d been collecting data from the app for over a month and had a couple dozen trips worth of data I could play back to evaluate the algorithm as I was redeveloping it. This time, I created a macOS app that gave me both greater playback control and more insight into the algorithm.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-train-tracker-viewer-paused.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;The paused state of the custom train tracker viewer app I built to develop/debug Eki Live&apos;s tracking algorithm&quot; title=&quot;The paused state of the custom train tracker viewer app I built to develop/debug Eki Live&apos;s tracking algorithm&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The paused state of the custom train tracker viewer app I built to develop/debug Eki Live&apos;s tracking algorithm&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I was still having trouble keeping all the edge cases in my head. Especially how unreliable GPS data is. I’d &lt;em&gt;usually&lt;/em&gt; get new coordinates from Core Location once per second, but not always. I’d &lt;em&gt;usually&lt;/em&gt; get coordinates within 20 meters of accuracy for above ground lines, but not always, and not when I needed them the most (at station boundaries). I had to make a lot of conflicting decisions about how to fill in gaps in the sensor data. I had to make peace again with using &lt;em&gt;only&lt;/em&gt; the sensor data I already had.&lt;/p&gt;

&lt;p&gt;At this point, I started to feel the weight of the decision-by-indecision to not ship V1 of Eki Live in early May. I had spent weeks getting the railway geopoint data and maybe a week on the new algorithm, but it still wasn’t obviously better than the existing algorithm I’d threw together in a couple days (that version itself being an evolution of other approaches).&lt;/p&gt;

&lt;p&gt;I used this panic and sunk cost fallacy indulgence to power through a couple more days of algorithm tweaking. Hour after hour tweaking constants and watching my ghost trains cruise along the same paths I’d see 1000 times at 10x speed.&lt;/p&gt;

&lt;video src=&quot;/images/eki-live-train-tracker-running.mov&quot; preload=&quot;none&quot; poster=&quot;/images/eki-live-train-tracker-running.jpg&quot; controls=&quot;&quot; height=&quot;450&quot;&gt;&lt;/video&gt;

&lt;p&gt;There wasn’t one particular breakthrough insight, but soon enough I did finally feel confident enough that I was ready to integrate the new algorithm, sand off the rough edges, and ship another beta.&lt;/p&gt;

&lt;h3 id=&quot;app-store-marketing&quot;&gt;App Store Marketing&lt;/h3&gt;

&lt;p&gt;The last piece of the puzzled I’d been putting off was the app icon, marketing images, and App Store copy.&lt;/p&gt;

&lt;p&gt;I’d re-learned a couple lessons from previous app icon attempts: an app icon can always can be visually simpler and more distinct and unique.&lt;/p&gt;

&lt;p&gt;I had sketched out a quick idea for an app icon in Procreate and used it for the early Test Flight builds.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-beta-app-icon.png&quot; width=&quot;&quot; height=&quot;250&quot; alt=&quot;Procreate-sketched app icon for Eki Live&apos;s Test Flight beta releases&quot; title=&quot;Procreate-sketched app icon for Eki Live&apos;s Test Flight beta releases&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Procreate-sketched app icon for Eki Live&apos;s Test Flight beta releases&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It wasn’t quite bold enough. Too much white space.&lt;/p&gt;

&lt;p&gt;I riffed on it using vector tools in Figma and ended up with something I like and feels right on my iOS home screen. I like it enough that I might redo Eki Bright’s icon with a variant of the idea.&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;250&quot; alt=&quot;App icon for Eki Live version 1.0&quot; title=&quot;App icon for Eki Live version 1.0&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;App icon for Eki Live version 1.0&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Similarly for the App Store marketing images, I wanted to go a little more splashy than plain screenshots of the app. There is certainly room for improvement, but I’m hoping a lot of the appeal hits by the third image.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-live-v1-app-store-marketing-screenshots.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Marketing screenshots for Eki Live version 1.0&quot; title=&quot;Marketing screenshots for Eki Live version 1.0&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Marketing screenshots for Eki Live version 1.0&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I went a little lighter on the App Store description than I previously had, and with a few last checks, Eki Live version 1.0 was ready to ship.&lt;/p&gt;

&lt;h3 id=&quot;submission-and-review&quot;&gt;Submission and review&lt;/h3&gt;

&lt;p&gt;My last bit of panic was realizing that I hadn’t yet got Eki Live through App Store review yet. Each Test Flight review had taken ~3 days but had passed.&lt;/p&gt;

&lt;p&gt;I was specifically worried because the main user value of Eki Live is that you don’t have to remember to open the app in order for it to start tracking and appear as a Live Activity. This functionality was disallowed by the ActivityKit APIs until iOS 17.2, when the server-driven push-to-start Live Activity API was released. Push-to-start is my workaround for starting a Live Activity in the background. Although there’s no rule against it in the App Store review guidelines (as far as I know), I was still concerned that Apple would view it as going against the spirit of the API.&lt;/p&gt;

&lt;p&gt;In any case, it was something I should have de-risked earlier in the month before I started rewriting the tracking algorithm. I could have submitted an early build even if I wasn’t planning on releasing it yet.&lt;/p&gt;

&lt;p&gt;Luckily, App Store approved v1 with little fanfare. For now, Eki Live remains in app review’s good graces.&lt;/p&gt;

&lt;h3 id=&quot;external-marketing&quot;&gt;External Marketing&lt;/h3&gt;

&lt;p&gt;I’m planning to get more serious about marketing for this app.&lt;/p&gt;

&lt;p&gt;For a while, I’ve been thinking about ways to leverage video-based social media (e.g. TikTok, Instagram Reels) as a free-to-play passive advertising channel for my apps, but couldn’t quite figure out the right video format.&lt;/p&gt;

&lt;p&gt;While out riding the trains doing a testing run, I was watching Eki Live while periodically looking out the train window onto the sunny Kanagawa suburban countryside. An idea hit that, hey, aren’t there lots of ASMR-like videos on social media of people just riding trains? Hadn’t I already spent hours upon hours staring somewhat mesmerized at Eki Live’s interface slowly ticking by?&lt;/p&gt;

&lt;p&gt;I spent a short morning coding up a debug-only accessible interface mod for the Eki Live in-app UI. It displays a live camera feed on the top third of the window, hides some UI, and can start/stop a screen recording using &lt;a href=&quot;https://developer.apple.com/documentation/replaykit&quot;&gt;ReplayKit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Afterwards, I hopped back on a mostly empty afternoon train out to Ofuna and recorded some videos that look like this:&lt;/p&gt;

&lt;video src=&quot;/images/eki-live-v1-camera-view-hongodai.mov&quot; preload=&quot;none&quot; poster=&quot;/images/eki-live-v1-camera-view-hongodai.jpg&quot; controls=&quot;&quot; height=&quot;400&quot;&gt;&lt;/video&gt;

&lt;p&gt;I’m not sure whether these will hit, but I will keep iterating and with some luck have a new passive stream of new users at top of funnel.&lt;/p&gt;

&lt;h3 id=&quot;next-steps&quot;&gt;Next steps&lt;/h3&gt;

&lt;p&gt;There’s plenty more on the horizon for Eki Live, but I’m hoping to first get some positive feedback on the direction before investing more development (and research) time. I realize that filling that top of the funnel with effective marketing will be critical in getting enough signal to make a call on whether or not to continue.&lt;/p&gt;

&lt;p&gt;If/when I do proceed, my next steps will be:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Improving the ability for the algorithm to detect train alighting and transfers.&lt;/li&gt;
  &lt;li&gt;Letting users receive alerts when they approach stations of their choice.&lt;/li&gt;
  &lt;li&gt;Allowing users who don’t want to allow background location permissions to still use the app in a streamlined way.&lt;/li&gt;
  &lt;li&gt;Enabling support for snoozing background tracking for arbitrary periods (e.g. if taking a vacation, going on a road trip).&lt;/li&gt;
  &lt;li&gt;Improving the ability for the algorithm to track in underground trains or other low-signal areas.&lt;/li&gt;
  &lt;li&gt;Add unique visualization of the journey on the map within the app.&lt;/li&gt;
  &lt;li&gt;Add static timetable support and arrival time estimates.&lt;/li&gt;
  &lt;li&gt;Add express train support.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;As an indie dev with near infinite freedom, I almost feel like it’s my obligation to take moonshots and experiment in ways that bigger companies can’t.&lt;/p&gt;

&lt;p&gt;Starting from the first prototype station departure Widgets of Eki Bright, the timetable lists, the DIY routing feature, and now the live automatic station tracking of Eki Live, I know I’m following this thread &lt;em&gt;somewhere&lt;/em&gt;. Whether it’ll be this idea or the next one that lands, my optimism is only growing.&lt;/p&gt;

&lt;p&gt;For all who’ve been following along on this journey, thanks for reading. Eki Live v1.0 will be available in the App Store soon.&lt;/p&gt;
</description>
        <pubDate>Thu, 29 May 2025 07:22:00 -0500</pubDate>
        <link>https://twocentstudios.com/2025/05/29/train-tracker-devlog-02/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/05/29/train-tracker-devlog-02/</guid>
        
        <category>ekibright</category>
        
        <category>ekilive</category>
        
        <category>ios</category>
        
        
      </item>
    
      <item>
        <title>Train Tracker Devlog</title>
        <description>&lt;p&gt;Last month, I took a step back from development of my train timetables &lt;a href=&quot;/2024/07/27/eki-bright-tokyo-area-train-timetables&quot;&gt;iOS app Eki Bright&lt;/a&gt; to think about the app in a broader context. I’ve iterated on Version 1 on and off for nearly a year, with use cases emerging out of a basic feature set and evolving with my own daily usage of the app.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-7-marketing.png&quot; width=&quot;&quot; height=&quot;250&quot; alt=&quot;Marketing screenshots for Eki Bright v1.7&quot; title=&quot;Marketing screenshots for Eki Bright v1.7&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Marketing screenshots for Eki Bright v1.7&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;As a solo developer, it’s difficult to maintain a clear perspective about any given project as it grows. It’s a balance of having a strong vision but carefully allowing reality to gently guide that vision.&lt;/p&gt;

&lt;p&gt;All this is to say I spent some time thinking hard about what version 2 of Eki Bright would look like if I started over today. How could I optimize the app for the way I use it now? How can I entice potential users and provide value to new users immediately?&lt;/p&gt;

&lt;p&gt;If I stopped with version 1, as a user, I’d be relatively satisfied. I know how to navigate the app and work around the various UX speed bumps and oil slicks to achieve my goal of riding the train system here in Tokyo. I can overlook the problems in the app in ways a random iPhone user wouldn’t. I knowingly stopped short of perfection on a few feature implementations in favor of getting them shipped.&lt;/p&gt;

&lt;p&gt;I started to see a vision for how the app could work in a &lt;em&gt;progressive enhancement&lt;/em&gt; sort of way for the various use cases I’ve uncovered. I started to see how important it was to do as much heavy lifting in the app as possible. There’s always going to be tension between a “semi-pro” app that gives the user full control while also doing work on their behalf without asking.&lt;/p&gt;

&lt;p&gt;A key part of the vision for version 2 that emerged was that Eki Bright can be a lot smarter about understanding the user’s context. With location services, it should be possible to understand whether the user is walking to a train station and wants to know if they should run to get the next train, or whether they’re riding a train and want to know when they’ll arrive at their destination.&lt;/p&gt;

&lt;p&gt;I started by segmenting out users into part of an app usage lifecycle:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;What would make a iPhone user want to download the app in the first place? Why (if at all) are Tokyo residents unsatisfied with their current navigation apps?&lt;/li&gt;
  &lt;li&gt;For a first time user, what feature could act as an immediate hook/wedge to provide value with zero setup or explanation and remind them to come back again the next day?&lt;/li&gt;
  &lt;li&gt;For users who have seen consistent results, what motivation would they have to want to dig deeper and trade some customization effort to get significantly more value out of the app?&lt;/li&gt;
  &lt;li&gt;For users who have used the app consistently for some time, what features can be enhanced automatically based on usage history?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The features that make up this theoretical system are quite complicated! An interface that adapts to the kind of user, the user’s usage history, and the user’s current context was somewhat of a overwhelming task for me to take on all at once.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v2-feature-list.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Sketching out the lattice of features that could make up Eki Bright v2&quot; title=&quot;Sketching out the lattice of features that could make up Eki Bright v2&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Sketching out the lattice of features that could make up Eki Bright v2&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;So after doing some brainstorming and pencil mockups, I decided to start prototyping a “hook” feature to capture that first segment of users: those who have not downloaded the app and first time users. A feature that is buzzy and attractive to prospective users, and is low touch and requires nearly zero configuration for first time users.&lt;/p&gt;

&lt;p&gt;That feature was a &lt;em&gt;train finder&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;With the timetable data embedded in the app combined with live location data from the user’s device, I reasoned it should be possible to find the exact train a user was riding if they opened Eki Bright while enroute. If the app could do this, it’d cut down on the work necessary to unlock downstream benefits for the user like:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Checking what time the train will arrive at a destination station&lt;/li&gt;
  &lt;li&gt;Setting an alarm for the destination station&lt;/li&gt;
  &lt;li&gt;Checking what other stations are stops along the way&lt;/li&gt;
  &lt;li&gt;Setting up a &lt;a href=&quot;/2025/01/24/eki-bright-the-case-for-diy-routing/&quot;&gt;DIY route&lt;/a&gt; to more thoroughly track a transfer&lt;/li&gt;
  &lt;li&gt;Sharing a route and arrival time to a friend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this point, I still intended the train finder feature to be part of Eki Bright. I imagined the user opening up the Eki Bright app along their journey, the app quickly booting up location services and narrowing down the possible railways and trains within a few seconds, and the user being able to quickly take some related actions from there.&lt;/p&gt;

&lt;p&gt;My friend David asked “why not have it run in the background so you don’t need to open the app?” I initially balked, not wanting to add background location tracking to Eki Bright due to its potential to be heavy on the device battery. I also couldn’t see how background tracking could streamline the experience beyond reducing that 1-2 second train calculation time with the tradeoff that all this work would waste battery in the cases the user never opened the app. The background activity idea stayed in the back of my mind though.&lt;/p&gt;

&lt;p&gt;I started prototyping the algorithm for turning a time-series of GPS coordinates into a railway, a direction on that railway, and ultimately a train.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/train-tracker-first-algorithm.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Thinking through an train tracking algorithm. This particular algorithm turned out to be a dud.&quot; title=&quot;Thinking through an train tracking algorithm. This particular algorithm turned out to be a dud.&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Thinking through an train tracking algorithm. This particular algorithm turned out to be a dud.&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;After my first day working on the algorithm, I realized that it was going to require a lot iteration on real data from inside the various trains running all over Tokyo. It wasn’t reasonable to think I could ride a train all day with my iPhone and MacBook debugging the algorithm on live data.&lt;/p&gt;

&lt;p&gt;I therefore spent a day creating an app for collecting sessions of GPS coordinates. This has turned out to be a huge boon for development efficiency.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/gps-collector-screens.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;GPS collector app I created and used to get batches of real data from the field&quot; title=&quot;GPS collector app I created and used to get batches of real data from the field&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;GPS collector app I created and used to get batches of real data from the field&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This personal-use GPS Collector app allows me to collect raw data from Core Location in the background and annotate it while riding the various routes I take around the Tokyo area. I divide each trip up into a &lt;em&gt;session&lt;/em&gt;, then manually annotate the session with the railway, direction, departure station, and arrival station to serve as ground truth annotations. I allow exporting in GPX format (for usage within Xcode) and as JSON I can import into and decode with other apps.&lt;/p&gt;

&lt;p&gt;Seeing the raw data revealed a litany of edge cases my algorithm would need to handle. First off, any train that goes underground is a non-starter for a GPS-reliant system; I’d have to make peace with that fact for now. Core Location data includes speed and heading, which is useful, but is itself a derived value and can be gleaned from other sources. GPS accuracy will sometimes plummet temporarily inside the boundaries of a station and sometimes randomly inside dense city limits. Waypoints are usually returned one-per-second, but sometimes will cut out for seconds or minutes. Some trains go from underground to above ground at least once along their designated route.&lt;/p&gt;

&lt;p&gt;I spent a week or so collecting GPS data while working on other apps. I returned to Eki Bright to finish up a first draft of an algorithm that took an entire time-series of GPS data and returned a ranked list of candidates: a railway, the direction on that railway, and the previous and next station.&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;RidingTrainFinderCandidate&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;railway&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Railway&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;direction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RailDirection&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;previousStation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Station&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;nextStation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Station&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// let train: TrainTimetable -- TODO: determine which train&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 added a debug viewer to visualize how my algorithm was responding to test data as it was played back. It was mostly working! It was also kind of fun to watch the playback. Being able to throw together a view like this for the sole purpose of debugging an algorithm is a huge win for SwiftUI.&lt;/p&gt;

&lt;video src=&quot;/images/train-tracker-debug-view-01.mp4&quot; controls=&quot;&quot; poster=&quot;/images/train-tracker-debug-view-01.png&quot; preload=&quot;none&quot; height=&quot;400&quot;&gt;&lt;/video&gt;

&lt;p&gt;I’d hit a development checkpoint, and as cool as my little debug tracker view was, I was still far from a shippable feature that solved a real problem. My next step was extending the algorithm to guess which train the user was on (not exactly a straightforward algorithm to write based on the shape of my train timetable data).&lt;/p&gt;

&lt;p&gt;However, I thought back to my friend David’s remark about an app that works in the background. I thought, if I freed myself from the artificial constraints of Eki Bright as it currently existed, how could this algorithm still be useful?&lt;/p&gt;

&lt;p&gt;A new vision emerged of an app that solved a much shallower problem:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Sometimes when I’m on a crowded train and I’ve got my headphones in, it’s hard to tell what station I’m approaching. I can’t see the display above the train car door or out the window.&lt;/li&gt;
  &lt;li&gt;What if I had a Live Activity in my Dynamic Island that updated live as I stopped at or passed each station along a railway?&lt;/li&gt;
  &lt;li&gt;And what if I didn’t have to manually select what railway I was on and what direction I was going?&lt;/li&gt;
  &lt;li&gt;Better yet, what if I &lt;em&gt;didn’t even have to open the app&lt;/em&gt; and the Live Activity would automatically appear when I was riding a train and disappear when I got off?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I could pull it off, this feature would be supplemental to any other navigation app. It also has a bit of “cool technology” vibe to it that could entice a download and serve as a conversation piece.&lt;/p&gt;

&lt;p&gt;Realizing this new vision came with its own new implementation challenges.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Monitor significant location changes in the background to save battery life, then switch to live location monitoring when moving at train speeds.&lt;/li&gt;
  &lt;li&gt;Detect the railway and railway direction.&lt;/li&gt;
  &lt;li&gt;Continuously update which stations have been visited and passed, and which station is next on the railway, even if it’s far away.&lt;/li&gt;
  &lt;li&gt;Start a Live Activity in the background when confidence in the current railway is high enough.&lt;/li&gt;
  &lt;li&gt;Update the Live Activity as the user approaches, arrives at, and departs a station.&lt;/li&gt;
  &lt;li&gt;End the Live Activity and switch back to monitoring significant location changes once the user has alighted their train.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I knew that significant location changes, app background activity, and the way each of these system features interacts with the relatively new (iOS 16+) Live Activities API was going to pose as the biggest risk to executing the seamless zero-touch app experience I envisioned.&lt;/p&gt;

&lt;p&gt;I started by creating a new app project and creating a GRDB-backed event logging system. Next, I configured the app to request background location permission. I then created the bones of a location tracking algorithm that preserved battery life. I logged app lifecycle events and events for my location tracking algorithm to ensure I could quickly debug why the app was or wasn’t “waking up” or “sleeping” when I expected it to while out in the field.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/train-tracker-app-events.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Log of app events so I can verify background behavior&quot; title=&quot;Log of app events so I can verify background behavior&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Log of app events so I can verify background behavior&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The next big task was reimagining my existing railway-finding algorithm for a different system lifecycle. This also meant I needed to pare down my very large train timetable static database for this new use case. I only needed the list of railways and stations. I followed a similar development flow as last time; I created a couple new debug views to view the live GPS waypoints and follow these waypoints on a map alongside the train tracking algorithm outputs.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/train-tracker-waypoints-view.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Raw waypoints view to allow confirmation of incoming data&quot; title=&quot;Raw waypoints view to allow confirmation of incoming data&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Raw waypoints view to allow confirmation of incoming data&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I started with a version that played back existing GPS data. A dashboard view showed just the user-facing data: the detected railway and “focus” station.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/train-tracker-debug-dashboard.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;A debug view that plays back previously captured GPS data at variable speed and shows user-facing data&quot; title=&quot;A debug view that plays back previously captured GPS data at variable speed and shows user-facing data&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A debug view that plays back previously captured GPS data at variable speed and shows user-facing data&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;There was also a list view showing the scores assigned by the algorithm and used to determine the ultimate result shown to the user.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/train-tracker-debug-list.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The derived scores used by the algorithm to determine what railway and focus station is shown to the user&quot; title=&quot;The derived scores used by the algorithm to determine what railway and focus station is shown to the user&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The derived scores used by the algorithm to determine what railway and focus station is shown to the user&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then, once I was satisfied with the algorithm accuracy on the snapshot data, I migrated this view to use only live device data.&lt;/p&gt;

&lt;p&gt;Watching the algorithm run live was exciting. I felt like I’d hit another checkpoint as the device would wake up and start gathering GPS data in the background, then start showing me which railway I was on and which station was next as soon as I opened it.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/train-tracker-debug-live-tracking.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Same as above but using live GPS data&quot; title=&quot;Same as above but using live GPS data&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Same as above but using live GPS data&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;With a basic (but admittedly incomplete) tracking algorithm proven, the last piece of the puzzle I needed to de-risk was starting the Live Activity automatically in the background. Unfortunately this is where I hit a frustrating roadblock.&lt;/p&gt;

&lt;p&gt;In a careful scan of the lengthy &lt;a href=&quot;https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities#Review-Live-Activity-presentations&quot;&gt;Live Activities documentation&lt;/a&gt;, I found the line:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Your app can only start Live Activities while it’s in the foreground. However, you can update or end a Live Activity from your app while it runs in the background.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I confirmed this artificial limitation by attempting it and logging errors in my event tracking database.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;LiveActivities Error: The operation couldn’t be completed. Target is not foreground&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I felt like requiring the user to open the app each time they wanted the live activity to run – even if it was as simple as opening and immediately closing the app – would be too tedious an ask as a prerequisite for daily usage.&lt;/p&gt;

&lt;p&gt;Before I neutered my vision or gave up on the idea entirely, I had one card left up my sleeve. From the documentation:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Starting with iOS 17.2 and iPadOS 17.2, you can also start Live Activities with ActivityKit push notifications.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So a push notification from a server can start a Live Activity without being initiated by a user (as I’d personally experienced with the Apple Sports app), but for some reason it can’t be started by the device itself? Strange, but since my app is already running in the background, I could technically fire off a network request to my own server with the data I wanted to start the Live Activity with and use my server as a pseudo-proxy to start a Live Activity. It feels like a loophole, but perhaps it’s simply a case of an Apple product manager not re-evaluating an initial safeguard after changing a related feature.&lt;/p&gt;

&lt;p&gt;Setting up push notifications is &lt;em&gt;involved&lt;/em&gt;. I really did not want to be on the hook for maintaining another set of dependencies, but it was the only option left on the table.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Aside: is it possible to send a push notification directly from a device instead of through an intermediary server controlled by the developer? In other words, could the device send a request to the APNS server directly that would send a push notification right back to it? In theory it seems possible, with the big security downside that the p8 key would need to be included in plain text within the app bundle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I’ll leave the long debug story of how I got push notifications working for another time, but after a couple days of development, I confirmed that I could indeed start a Live Activity from the background using an intermediary server.&lt;/p&gt;

&lt;p&gt;Whether or not the App Store app review team considers this to be a permitted workaround is still a huge risk. I’m not sure how I can determine their stance without finishing up version 1 of the app and submitting it for review. Even an unfinished version going through Test Flight review isn’t a guarantee App Store review will also approve.&lt;/p&gt;

&lt;p&gt;So this is my current checkpoint: a new app binary with lot of debug screens that starts and updates Live Activities from the background as the user rides a railway in Tokyo.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/train-tracker-proto-live-activity.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Prototype version of the working train tracker Live Activity on the lock screen&quot; title=&quot;Prototype version of the working train tracker Live Activity on the lock screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Prototype version of the working train tracker Live Activity on the lock screen&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/train-tracker-proto-dynamic-island.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Prototype version of the working train tracker Live Activity in the Dynamic Island&quot; title=&quot;Prototype version of the working train tracker Live Activity in the Dynamic Island&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Prototype version of the working train tracker Live Activity in the Dynamic Island&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My plan is to release this app standalone. How it fits into the existing and future Eki Bright vision isn’t yet determined. Perhaps the train tracker app is a free marketing driver for Eki Bright. Perhaps the train tracker app evolves separately from Eki Bright and eventually obsoletes Eki Bright. I’m not sure, but my instinct is to test it in the market in isolation first.&lt;/p&gt;

&lt;p&gt;What do I need to finish up in order to ship?&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Improve the algorithm to determine the correct railway faster, handle transfers, and off-board seamlessly.&lt;/li&gt;
  &lt;li&gt;Improve the design of the Live Activity.&lt;/li&gt;
  &lt;li&gt;Remove the debug screens and rework the in-app UI for onboarding, settings, and simple monitoring.&lt;/li&gt;
  &lt;li&gt;Create branding and add all the required info for the App Store.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’m getting faster at getting through this part of the process, but it still takes time. However, I do feel some accomplishment in having semi-efficiently prototyped enough to de-risk this project.&lt;/p&gt;

&lt;p&gt;I’m planning to write a few technical posts that detail the caveats of Live Activities once I’m more confident in the robustness of my implementation. Until then.&lt;/p&gt;
</description>
        <pubDate>Tue, 15 Apr 2025 12:06:00 -0500</pubDate>
        <link>https://twocentstudios.com/2025/04/15/train-tracker-checkpoint-devlog/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/04/15/train-tracker-checkpoint-devlog/</guid>
        
        <category>ekibright</category>
        
        <category>ekilive</category>
        
        <category>ios</category>
        
        
      </item>
    
      <item>
        <title>Eki Bright - Open Data Challenge for Public Transportation 2024 Entry</title>
        <description>&lt;p&gt;&lt;a href=&quot;https://twocentstudios.com/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright&lt;/a&gt;, my iOS app for train timetables in the Tokyo metropolitan area, uses data from the &lt;a href=&quot;https://www.odpt.org/&quot;&gt;Public Transportation Open Data Center&lt;/a&gt; (ODPT). ODPT recently concluded their 5th contest, &lt;a href=&quot;https://challenge2024.odpt.org/index-e.html&quot;&gt;Open Data Challenge for Public Transportation 2024&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;We look forward to enthusiastic challenges from developers around the globe, aiming to create new services utilizing this data, solve social issues, and drive regional revitalization.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is a short retrospective on my participation.&lt;/p&gt;

&lt;p&gt;I had already started work on Eki Bright months before the challenge was announced. My aim for the app was always to optimize the daily experience of seasoned train riders in Tokyo. But I decided it wouldn’t hurt to participate in the contest.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://challenge2024.odpt.org/index-e.html#:~:text=Data%20Challenge%202024-,Past%20Grand%20Prizes,-1st%0ATokyo%20Trains&quot;&gt;past winners&lt;/a&gt; had various themes and target users. Some maps, some straightforward apps like mine, some chat bots. Most weren’t particularly well designed, so I thought the modern iOS look of Eki Bright could help it stand out.&lt;/p&gt;

&lt;p&gt;The entry submission deadline was January 17th and the final ceremony was February 15th, 2025. I submitted Eki Bright version 1.7.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-7-marketing.png&quot; width=&quot;&quot; height=&quot;250&quot; alt=&quot;Marketing screenshots for Eki Bright v1.7&quot; title=&quot;Marketing screenshots for Eki Bright v1.7&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Marketing screenshots for Eki Bright v1.7&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Unfortunately, the contest submission required a lot more than just the app. I spent valuable time creating a PDF introduction of the app’s features and benefits, a &lt;a href=&quot;https://youtu.be/YBhdSvepFB0&quot;&gt;2-minute marketing video&lt;/a&gt;, an instruction manual, an additional 3-minute deck for a pre-judging, and did a 10-minute interview with a few judges.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-contest-introduction-pdf.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Introduction deck for Eki Bright&quot; title=&quot;Introduction deck for Eki Bright&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Introduction deck for Eki Bright&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The upside was that, up until this point, I hadn’t &lt;em&gt;really&lt;/em&gt; sat down and spent time polishing my marketing message for the app. The app’s use case had evolved over the months of development and it wasn’t clear to me how to sell it to prospective users.&lt;/p&gt;

&lt;p&gt;In the end, there were ~500 entries and 17 finalists. I wasn’t selected but attended the final presentations and awards ceremony. I understood &lt;a href=&quot;https://challenge2024.odpt.org/award.html&quot;&gt;based on the finalists and winners&lt;/a&gt; why Eki Bright wasn’t what the judges were looking for. Translated from the &lt;a href=&quot;https://www.odpt.org/2025/02/17/%e3%80%8c%e5%85%ac%e5%85%b1%e4%ba%a4%e9%80%9a%e3%82%aa%e3%83%bc%e3%83%97%e3%83%b3%e3%83%87%e3%83%bc%e3%82%bf%e3%83%81%e3%83%a3%e3%83%ac%e3%83%b3%e3%82%b82024-powered-by-project-links-%e3%80%8d/&quot;&gt;announcement post&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;In addition, many of the entries themselves were rich in policy suggestions, not only for solutions that improve transportation convenience, but also for solutions that address recent issues in Japan, such as eliminating “transportation vacancies,” improving business productivity, and coordinating with urban development.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Eki Bright is aiming to solve similar problems as existing transportation utilities from a different angle. It’s not intended to be a research project or a prototype for a hypothetical user problem, and my submission materials didn’t try to present it as such. Prior to the pre-judging interview (and probably after) the judges had not downloaded Eki Bright, so none of the creature comforts of a well-crafted iOS app would be apparent to them.&lt;/p&gt;

&lt;p&gt;With hindsight I shouldn’t have spent time on the submission. But with the information I had at the time, it made sense to gamble some time for the chance of wider exposure for Eki Bright or even some of the prize money.&lt;/p&gt;

&lt;p&gt;Regardless of the disappointing result for Eki Bright, it was still a great experience. I liked seeing the other entries and I like the winner  &lt;a href=&quot;https://nishikata-tokotoko.github.io/cycle-shortcut-map/&quot;&gt;Cycle-Shortcut&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Now that I’m unblocked by some of the entry requirements of the contest, I have some next steps lined up for Eki Bright and I’m looking forward to getting started on it again.&lt;/p&gt;
</description>
        <pubDate>Tue, 18 Feb 2025 06:22:00 -0600</pubDate>
        <link>https://twocentstudios.com/2025/02/18/open-data-challenge-entry/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/02/18/open-data-challenge-entry/</guid>
        
        <category>ekibright</category>
        
        
      </item>
    
      <item>
        <title>Fixing the Crash: ActivityKit is Unavailable on macOS</title>
        <description>&lt;p&gt;If you have an iOS app that:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;supports “Designed for iPad” or “Designed for iPhone” and is on the Mac App Store (or is otherwise available on macOS)&lt;/li&gt;
  &lt;li&gt;uses the ActivityKit framework&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then your app will crash on macOS when you reference an ActivityKit symbol (through at least iOS 18.2).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/activity-kit-macos-crash.png&quot; width=&quot;&quot; height=&quot;250&quot; alt=&quot;Welcome to Crashville&quot; title=&quot;Welcome to Crashville&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Welcome to Crashville&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;How to fix it:&lt;/p&gt;

&lt;h3 id=&quot;link-activitykitframework-as-optional&quot;&gt;Link ActivityKit.framework as optional&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Go to project -&amp;gt; app target -&amp;gt; &lt;em&gt;Link Binary With Libraries&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;Add ActivityKit.framework&lt;/li&gt;
  &lt;li&gt;Set ActivityKit.framework’s status as &lt;em&gt;Optional&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;Repeat for the widget app extension target as well&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/activity-kit-macos-link-optional.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Link ActivityKit.framework as optional in app target and widget target&quot; title=&quot;Link ActivityKit.framework as optional in app target and widget target&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Link ActivityKit.framework as optional in app target and widget target&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;avoid-calling-activitykit-symbols-in-your-code&quot;&gt;Avoid calling ActivityKit symbols in your code&lt;/h3&gt;

&lt;p&gt;There are a lot of different ways to conditionally reference ActivityKit symbols.&lt;/p&gt;

&lt;p&gt;Conditional referencing must be done at runtime since even when running on macOS the compiler directive &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#if canImport(ActivityKit)&lt;/code&gt; will still evaluate to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if !ProcessInfo.processInfo.isiOSAppOnMac&lt;/code&gt; to short circuit code that shouldn’t run on macOS.&lt;/p&gt;

&lt;p&gt;In the case of &lt;a href=&quot;https://twocentstudios.com/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright&lt;/a&gt;, I have my direct usage of ActivityKit behind a dependency, defined and configured with the &lt;a href=&quot;https://github.com/pointfreeco/swift-dependencies&quot;&gt;swift-dependencies&lt;/a&gt; library. This allows me to swap out a fully functional dependency with a dummy dependency at launch time.&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;c1&quot;&gt;/// LiveActivityClient.swift&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ActivityKit&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ComposableArchitecture&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;WidgetKit&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;typealias&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ActivityID&lt;/span&gt; &lt;span class=&quot;o&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;c1&quot;&gt;// Same as `Activity.ID?`&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;@DependencyClient&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;LiveActivityClient&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;startOrReplaceRouteActivity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&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;routeItem&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RouteItem&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ActivityID&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;updateOrEndRouteActivity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&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;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&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;kd&quot;&gt;extension&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;LiveActivityClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;DependencyKey&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;liveValue&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;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;startOrReplaceRouteActivity&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;routeItem&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;segmentActivePhases&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
            &lt;span class=&quot;c1&quot;&gt;/// Call real implementation of `Activity.request`, etc.&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;updateOrEndRouteActivity&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;now&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
            &lt;span class=&quot;c1&quot;&gt;/// Call real implementation of `activity.update`, `activity.end`, etc.&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;static&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;unavailableValue&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;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;nv&quot;&gt;startOrReplaceRouteActivity&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;_&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;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&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;updateOrEndRouteActivity&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;_&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&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;p&gt;Then in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;App.swift&lt;/code&gt; file I use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.unavailableValue&lt;/code&gt; instead of the default &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.liveValue&lt;/code&gt; on macOS:&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;@main&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TrainApp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;App&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;store&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;Store&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;initialState&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;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;RootFeature&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;nv&quot;&gt;withDependencies&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;if&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ProcessInfo&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;processInfo&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isiOSAppOnMac&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;liveActivity&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;unavailableValue&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// ActivityKit framework crashes on macOS&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;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Scene&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;WindowGroup&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;RootView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;store&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;n&quot;&gt;store&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;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I can then use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Dependency(\.liveActivity) var liveActivity&lt;/code&gt; in any one of my features.&lt;/p&gt;

&lt;p&gt;Of course, the implementation of your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unavailableValue&lt;/code&gt; can also throw specific errors handled by your feature code. In my case, the LiveActivity silently failing on macOS is acceptable.&lt;/p&gt;

&lt;h3 id=&quot;hardening-your-widget-extension&quot;&gt;Hardening your widget extension&lt;/h3&gt;

&lt;p&gt;If you’re using ActivityKit.framework, then you may have a widget extension that configures the LiveActivity. In my case, I have a normal widget as well as a LiveActivity widget. In order to conditionally enable the LiveActivity widget on non-macOS platforms, I’m using the following technique from &lt;a href=&quot;https://stackoverflow.com/a/72807287&quot;&gt;this Stack Overflow post&lt;/a&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;@main&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;WidgetLauncher&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;main&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;if&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ProcessInfo&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;processInfo&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isiOSAppOnMac&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;WidgetOnlyBundle&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;main&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;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kt&quot;&gt;WidgetActivityBundle&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;main&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;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;WidgetOnlyBundle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;WidgetBundle&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;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Widget&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;StationBookmarkWidget&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;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;WidgetActivityBundle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;WidgetBundle&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;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;some&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Widget&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;StationBookmarkWidget&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;kt&quot;&gt;RouteActivityWidget&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;p&gt;However, there are some bugs with macOS widgets in Xcode 16.2 that I haven’t found a workaround for yet. I can’t 100% say this technique works, but if the default configuration doesn’t work for you, try the above and see if it helps. I’m still &lt;a href=&quot;https://hachyderm.io/@twocentstudios/113887068005326578&quot;&gt;pretty confused&lt;/a&gt; about how to efficiently test and debug widgets on macOS, so I don’t have a lot of guidance for this part.&lt;/p&gt;

&lt;h3 id=&quot;references&quot;&gt;References&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://stackoverflow.com/q/75589730&quot;&gt;Stack Overflow: Launching a designed for iPad mac app crashes at startup: Library not loaded&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://stackoverflow.com/a/72807287&quot;&gt;Stack Overflow: WidgetBundle return widgets based on some logic&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://forums.developer.apple.com/forums/thread/773125&quot;&gt;Apple Developer Forums: WidgetKit Simulator with Intent Configurations&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/widgetkit/debugging-widgets&quot;&gt;Apple Developer Documentation: Debugging Widgets&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Sat, 25 Jan 2025 05:10:00 -0600</pubDate>
        <link>https://twocentstudios.com/2025/01/25/activitykit-unavailable-on-macos/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/01/25/activitykit-unavailable-on-macos/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>ekibright</category>
        
        
      </item>
    
      <item>
        <title>Eki Bright - The Case for DIY Routing</title>
        <description>&lt;p&gt;When I set out making the first prototypes of &lt;a href=&quot;https://twocentstudios.com/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright&lt;/a&gt;, my train timetables iOS app for the Tokyo metropolitan area, I had no intentions of tackling routing. In fact, that was one of the selling points; the lack of routing, like lack of maps, made it visually and conceptually simpler for solving the problem of getting the next train departure time at any particular station.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-diy-route-example.jpg&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;A DIY route in Eki Bright as it appears in the bottom Route Bar&quot; title=&quot;A DIY route in Eki Bright as it appears in the bottom Route Bar&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A DIY route in Eki Bright as it appears in the bottom Route Bar&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I eventually did add routing, in a form I call &lt;em&gt;DIY routing&lt;/em&gt;, but it grew organically out of the existing feature set, and it stays within the same niche as I’ve been targeting thus far: train riders who know where they’re going and how to get there. A tool for &lt;em&gt;power users&lt;/em&gt;, so-to-speak.&lt;/p&gt;

&lt;p&gt;In this post, I want to make the case for DIY routing: why it’s a useful addition to the full-featured routing apps we all use regularly. I’ve never used anything like DIY routing before, so either it’s already obsolete, or the problem was solved &lt;em&gt;well enough&lt;/em&gt; by other apps that no one had bothered to explore other solutions until now.&lt;/p&gt;

&lt;p&gt;I’ll use Google Maps the illustrative example of a &lt;em&gt;full-featured&lt;/em&gt; routing app. I’ll use 乗換案内 (Norikae Annai or Japan Transit Planner in English) as the illustrative example of a &lt;em&gt;railway-only&lt;/em&gt; routing app. And this post will be focused on the Tokyo-area of Japan.&lt;/p&gt;

&lt;h2 id=&quot;what-is-diy-routing&quot;&gt;What is DIY routing?&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Full-featured routing&lt;/strong&gt; is choosing your departure point (often “current location” via GPS) and your destination point and allowing a routing algorithm propose several route options to choose from. Each routing option will often include multiple modes (e.g. walk, train, bus) and be optimized based on some goal (e.g. soonest arrival time, cost, complexity).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-gmaps-full-featured.png&quot; width=&quot;&quot; height=&quot;200&quot; alt=&quot;A full-featured routing interface in Google Maps where the departure and destination points are required in order to calculate a route&quot; title=&quot;A full-featured routing interface in Google Maps where the departure and destination points are required in order to calculate a route&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A full-featured routing interface in Google Maps where the departure and destination points are required in order to calculate a route&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In contrast, &lt;strong&gt;DIY routing&lt;/strong&gt; is documenting your own train-based route segment-by-segment starting from the departure station. A segment consists of one train and its departure station and arrival station, and therefore scheduled departure and arrival times. Multiple segments can be combined with transfers in-between.&lt;/p&gt;

&lt;p&gt;A completed two segment route with one transfer looks like this:&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-diy-route-basha-ebisu.png&quot; width=&quot;&quot; height=&quot;200&quot; alt=&quot;A 2-segment DIY route with a transfer at Nakameguro 中目黒&quot; title=&quot;A 2-segment DIY route with a transfer at Nakameguro 中目黒&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A 2-segment DIY route with a transfer at Nakameguro 中目黒&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;And a screencast of what it looks like assembling this two segment route in the app.&lt;/p&gt;

&lt;video src=&quot;/images/eki-bright-diy-route-create.mp4&quot; controls=&quot;&quot; preload=&quot;none&quot; poster=&quot;/images/eki-bright-diy-route-create.png&quot; width=&quot;300&quot;&gt;&lt;/video&gt;

&lt;p&gt;After you’ve created a route, the pertinent details update automatically as a Live Activity in the Dynamic Island and on the lock screen.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-diy-route-dynamic-island.png&quot; width=&quot;&quot; height=&quot;200&quot; alt=&quot;A DIY route as it appears in the Dynamic Island compact view&quot; title=&quot;A DIY route as it appears in the Dynamic Island compact view&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A DIY route as it appears in the Dynamic Island compact view&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-diy-route-lock-screen.png&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;A DIY route as it appears as a Live Activity in the lock screen before departure&quot; title=&quot;A DIY route as it appears as a Live Activity in the lock screen before departure&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A DIY route as it appears as a Live Activity in the lock screen before departure&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;the-use-cases-for-diy-routing&quot;&gt;The use cases for DIY routing&lt;/h2&gt;

&lt;p&gt;You may be wondering, “if I already know how to get to my destination without the aid of an algorithmic route service, why would I go through the trouble of creating one myself each time I take a trip?”&lt;/p&gt;

&lt;p&gt;Sure, I sometimes use the station timetable widgets I’ve set up to optimize leaving the house to catch the next train.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-widget-category-color.jpg&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;Using a widget to check train times before leaving the house&quot; title=&quot;Using a widget to check train times before leaving the house&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Using a widget to check train times before leaving the house&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;But other times I plan ahead maybe an hour or two to ensure I catch the (fastest) limited express train while also getting to my destination in time. I do this quickly by setting up the first departure of a DIY route, and the departure time immediately appears in my dynamic island so I can keep an eye on it.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-diy-route-dynamic-island.png&quot; width=&quot;&quot; height=&quot;200&quot; alt=&quot;Checking my planned departure in the dynamic island while doing something else&quot; title=&quot;Checking my planned departure in the dynamic island while doing something else&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Checking my planned departure in the dynamic island while doing something else&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Sometimes I’ll set up the full route, but other times I’ll only set the initial departure and set up the rest of the DIY route while I’m waiting on the platform or even when I’m already on the train. No need to do it all at once.&lt;/p&gt;

&lt;p&gt;If you’ve got a whole DIY route set up, you get the following benefits:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Remember when to leave your current location to catch the train you want.&lt;/li&gt;
  &lt;li&gt;Remember which train to board when you get to the departure station.&lt;/li&gt;
  &lt;li&gt;Remember when to get off at the transfer station.&lt;/li&gt;
  &lt;li&gt;Remember which train to board at the transfer station.&lt;/li&gt;
  &lt;li&gt;Remember when to get off at your arrival station.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All while browsing other apps or while your iPhone is locked.&lt;/p&gt;

&lt;h3 id=&quot;use-case-flexibility-in-departure&quot;&gt;Use case: flexibility in departure&lt;/h3&gt;

&lt;p&gt;When I’m picking my departure time in Eki Bright, I’m immediately presented with the full list, including train type (e.g. local, express). It’s quick and easy to understand at a glance what my options are. The interface has only a slight bias for “leaving now” departures, showing the next 6 departures on the station detail screen and the next ~11 departures on the station timetable screen. It’s not much more difficult to plan an hour or two ahead.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-station-timetable-station-detail.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Departures as they appear on the Station Timetable and Station Detail screens&quot; title=&quot;Departures as they appear on the Station Timetable and Station Detail screens&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Departures as they appear on the Station Timetable and Station Detail screens&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Similarly, once you’ve added a departure to a DIY route, you can see and select 2 departures before and after the active departure. This lets you quickly recover if you miss your train or decide to leave a little early.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-alternate-departures.png&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;Alternate departures shown when tapping the departure station 馬車道&quot; title=&quot;Alternate departures shown when tapping the departure station 馬車道&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Alternate departures shown when tapping the departure station 馬車道&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In contrast, other routing apps are purely optimized for “leaving now” departures, and are forced to use a variant of the time picker control in a modal view if you’re leaving even a little later.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-gmaps-departure-time.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Choosing a departure time in Google Maps&quot; title=&quot;Choosing a departure time in Google Maps&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Choosing a departure time in Google Maps&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Other routing apps also have various interfaces for re-routing to the next or previous train. But I’ve found each implementation to be lacking, either in update speed or UI clarity, mostly because the interfaces need to assist users who aren’t familiar with the route.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-applemaps-alternate-departures.png&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;List of alternate departures in Apple Maps&quot; title=&quot;List of alternate departures in Apple Maps&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;List of alternate departures in Apple Maps&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;use-case-no-false-sense-of-accuracy-in-walking-transfer-times&quot;&gt;Use case: no false sense of accuracy in walking transfer times&lt;/h3&gt;

&lt;p&gt;Full-featured routing apps default to choosing the start location of your route via GPS and then calculating the train portion of the route based on the best estimate walk time to the departure station. I think this method works fine for general users.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-gmaps-walking-to-train.png&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;Google Maps showing walking directions to the departure station&quot; title=&quot;Google Maps showing walking directions to the departure station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Google Maps showing walking directions to the departure station&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;However, as a power-user, I’ve found these estimates to be inaccurate to the point where they’re disruptive to my route planning.&lt;/p&gt;

&lt;p&gt;First, GPS accuracy is often quite spotty in many parts of street-level Tokyo, and even worse if you’re in one of the many underground spaces.&lt;/p&gt;

&lt;p&gt;The app must also make a tradeoff between waiting for the GPS signal to stabilize and providing a route. Waiting longer may return a more accurate GPS location, but may cause the user to become impatient, or even miss a train in rare cases.&lt;/p&gt;

&lt;p&gt;Since the walking shares the street with cars and buses, due to traffic light timings walking time estimates will always need to build in some margin of error.&lt;/p&gt;

&lt;p&gt;And finally, full-featured routing apps have no setting for “I’m a slow walker” or “I can run if it means I catch the express train and therefore a ~15 minute earlier arrival time”. This means they sometimes won’t show you a route you could easily make unless you set the departure time back a minute or two.&lt;/p&gt;

&lt;p&gt;It’s frustrating to try to work around these apps when they’re being “smart”. I need to enter my departure coordinates exactly by typing or fiddling with the map view. Or I need to open up the departure time picker and guess and check spinning the dials enough to trigger a more ideal set of route results.&lt;/p&gt;

&lt;p&gt;Many times, I’ve been on the station platform trying to quickly double check the info for a soon-to-depart train, but Google Maps will not show me that train because it thinks I need to walk 5 minutes to the train station due to the GPS accuracy.&lt;/p&gt;

&lt;p&gt;Norikae Annai assumes you’re already at the departure station and provides no walk guidance or departure time adjustment. This default configuration is fine for when you’re already at the station, but slow if you want to account for a couple minute walk.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-norikae-annai-route-setup.jpg&quot; width=&quot;&quot; height=&quot;250&quot; alt=&quot;Creating a route with Norikae Annai requires selecting a departure and arrival station&quot; title=&quot;Creating a route with Norikae Annai requires selecting a departure and arrival station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Creating a route with Norikae Annai requires selecting a departure and arrival station&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;You either need to use the departure time picker modal or tap through to other departures (if you can find those buttons between the ads).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-norikae-annai-alternate-departures.png&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;Choosing alternate departures in Norikae Annai (the orange buttons between the ad views)&quot; title=&quot;Choosing alternate departures in Norikae Annai (the orange buttons between the ad views)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Choosing alternate departures in Norikae Annai (the orange buttons between the ad views)&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;use-case-optimizing-transfer-times&quot;&gt;Use case: optimizing transfer times&lt;/h3&gt;

&lt;p&gt;Estimating transfer times between segments is a variant of the above problem of estimating walking times to the departure station.&lt;/p&gt;

&lt;p&gt;In Eki Bright, this problem is handled the same way as above. The app does not try to make any smart estimates it can’t guarantee, but instead gives you tools and surfaces relevant information to optimize transfers on your own.&lt;/p&gt;

&lt;p&gt;If I know my route and transfer pretty well, I can estimate my absolute fastest time walking from platform to platform. From there, I can quickly see the next couple departure options and easily decide whether I can rush to make the next transfer or whether I can take my time (perhaps stopping for a drink, or snack, or to use the restroom).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-diy-route-transfer-alternate-departures.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Alternate departures for a transfer shown in the bottom half of the screen above the route bar. I know this transfer occurs on the same platform, so one minute is enough.&quot; title=&quot;Alternate departures for a transfer shown in the bottom half of the screen above the route bar. I know this transfer occurs on the same platform, so one minute is enough.&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Alternate departures for a transfer shown in the bottom half of the screen above the route bar. I know this transfer occurs on the same platform, so one minute is enough.&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Google Maps has a reasonably good interface for checking other transfer time options. But as far as selecting a default option, it seems to be using its walking distance algorithm even within stations. For this example Nakameguro transfer, it seems to think the transfer will take a 1 minute walk, even though these two trains actually stop on adjacent sides of the same platform and usually wait for one another.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-gmaps-nakame-transfer.png&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Google Maps chooses the 22:51 departure but shows the 22:47 departure in a dropdown menu&quot; title=&quot;Google Maps chooses the 22:51 departure but shows the 22:47 departure in a dropdown menu&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Google Maps chooses the 22:51 departure but shows the 22:47 departure in a dropdown menu&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Noriakae Annai doesn’t even have an option for choosing an alternate transfer. It’s not clear to me how the app chooses possible transfer times by default. But in the below example, I can see in Eki Bright that if I get off the Toyoko-line train right after it arrives, I have a good chance of making the Hibiya-line transfer departing at the exact same time.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-norikae-annai-nakame-transfer.png&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Norikae Annai also shows the 22:51 departure, but has no option to show the user the 22:47 option&quot; title=&quot;Norikae Annai also shows the 22:51 departure, but has no option to show the user the 22:47 option&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Norikae Annai also shows the 22:51 departure, but has no option to show the user the 22:47 option&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-diy-route-nakame-transfer.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Eki Bright shows the 22:47 transfer option by &apos;default&apos;, but it&apos;s easy to see/select the alternate 22:51 departure as well&quot; title=&quot;Eki Bright shows the 22:47 transfer option by &apos;default&apos;, but it&apos;s easy to see/select the alternate 22:51 departure as well&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Eki Bright shows the 22:47 transfer option by &apos;default&apos;, but it&apos;s easy to see/select the alternate 22:51 departure as well&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;use-case-eliminating-distractions-like-maps&quot;&gt;Use case: eliminating distractions like maps&lt;/h3&gt;

&lt;p&gt;Other routing apps dedicate most of their UI to departure point, arrival point, routing options, maps, and proposed routes.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-gmaps-default-route-select.png&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Google Maps&apos; default route selection screen&quot; title=&quot;Google Maps&apos; default route selection screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Google Maps&apos; default route selection screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;If you already know which proposed route you want to take, but just need to know the departure time, the rest of the UI is just distraction and visual noise.&lt;/p&gt;

&lt;p&gt;In contrast, Eki Bright is optimized to get you, a power user, to your first departure time as quickly as possible. Since user preferences and situations are different, I use a layering approach: lock screen widgets, today view widgets, home screen widgets, and bookmarks on the app’s home screen.&lt;/p&gt;

&lt;p&gt;Priority for routing is secondary, since it can be ignored completely or set up en route with no consequences.&lt;/p&gt;

&lt;p&gt;Eliminating the necessity of choosing a destination is a big win for Eki Bright.&lt;/p&gt;

&lt;p&gt;Eliminating the necessity of a map is also a big win. Maps take up a lot of screen real estate.&lt;/p&gt;

&lt;p&gt;Other routing apps, even Google Maps, have their own version of timetable-based UI. However, the UI and UX is usually a secondary concern and quite clumsy. They’re not intended to be a full featured replacement for routing, nor do they incorporate progressive disclosure where you can use the departure time as a jumping off point to create a route.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-gmaps-station-departures.png&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Google Maps station departures screen for Ebisu station&quot; title=&quot;Google Maps station departures screen for Ebisu station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Google Maps station departures screen for Ebisu station&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;use-case-browsing-waypoints-while-en-route&quot;&gt;Use case: browsing waypoints while en route&lt;/h3&gt;

&lt;p&gt;When using Google Maps for routing, it’s not possible to browse waypoints like restaurants while you’re in the middle of navigating. Using another app for routing (not only Eki Bright) allows you to still search for a restaurant at your destination while still being able to keep track of your departure time.&lt;/p&gt;

&lt;h2 id=&quot;when-does-it-not-make-sense-to-use-diy-routing&quot;&gt;When does it not make sense to use DIY routing?&lt;/h2&gt;

&lt;h3 id=&quot;unfamiliar-routes&quot;&gt;Unfamiliar routes&lt;/h3&gt;

&lt;p&gt;Straight up, if you don’t know how to get from your departure station to your arrival station, it will be frustrating and difficult (but not impossible) to derive an ideal route using the Eki Bright UX.&lt;/p&gt;

&lt;h3 id=&quot;comparing-multiple-routes&quot;&gt;Comparing multiple routes&lt;/h3&gt;

&lt;p&gt;If you think you have the option of using two different routes, but aren’t sure which is better (i.e. faster, cheaper), Eki Bright will not be useful in making that decision.&lt;/p&gt;

&lt;h3 id=&quot;ultra-short-routes-with-no-fixed-schedule&quot;&gt;Ultra-short routes with no fixed schedule&lt;/h3&gt;

&lt;p&gt;The Yamanote line only has one type (“local”) and comes quite frequently (every ~3-4 minutes). Although it has a published schedule, in most cases trains will not wait for their departure time. This makes it ill suited to plan around if it’s the only segment of a trip. You’ll usually want to go to the platform whenever you’re ready to leave (odds are you won’t need to wait long). This is the dream of all public transportation, right?&lt;/p&gt;

&lt;h3 id=&quot;planning-over-one-day-in-advance&quot;&gt;Planning over one day in advance&lt;/h3&gt;

&lt;p&gt;Although Eki Bright has access to timetables for weekdays, weekends, and holidays, selecting a schedule other than the current day’s is not currently supported. Also, DIY routes are assumed to be temporary and reset at the end of the day. Therefore, you can’t use Eki Bright to plan routes in advance.&lt;/p&gt;

&lt;h2 id=&quot;my-progression-of-designing-diy-routing&quot;&gt;My progression of designing DIY routing&lt;/h2&gt;

&lt;p&gt;Eki Bright started as a list of stations and the station timetable for each.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-station-list-timetable-screens.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The station list and station timetable screens in version 1.0&quot; title=&quot;The station list and station timetable screens in version 1.0&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The station list and station timetable screens in version 1.0&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Right before launch, I decided to add the train timetable for each departure as a third layer of the navigation.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-train-timetable-multiple-railways.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The train timetable screen with a train that runs multiple railways&quot; title=&quot;The train timetable screen with a train that runs multiple railways&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The train timetable screen with a train that runs multiple railways&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This was arguably unnecessary, but as soon as I added it, I immediately found it useful. I could now:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Check which stations any train stopped at.&lt;/li&gt;
  &lt;li&gt;Check the arrival time of the train at my destination station.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a single-segment trip, this was useful enough. I started using Eki Bright for more than I originally expected to.&lt;/p&gt;

&lt;p&gt;However, this interface didn’t work for two segment trips that required a transfer. To work around the limitation, I needed to make a mental note of the arrival time of the first segment, then go back to the home screen and search for that station. But this would mean I lost access to the timetable of the first train.&lt;/p&gt;

&lt;p&gt;From here, the next logical step was linking a station in the train timetable screen to its station timetable. This would make it quicker to tap through and see the departure and arrival times for the full route, but I’d need to pop the stack to see earlier times.&lt;/p&gt;

&lt;p&gt;After creating some other features, I finally decided to tackle routing. My idea was maintain a bottom toolbar that floated above all screens and showed the route as the user was assembling it. I added a button to each station on the train timetable screen to allow the user to add a departure or arrival station to a route segment.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-route-bar-train-timetable.png&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Train timetable screen with add-to-route buttons at each station&quot; title=&quot;Train timetable screen with add-to-route buttons at each station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Train timetable screen with add-to-route buttons at each station&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This UI immediately solved a lot of my problems. The implementation was more difficult than I expected though. I wanted to support alternate departures out of the gate, and alternate departures need to account for a selected destination segment since not all trains go to all destinations. Plus I needed to show the user when the route configuration was not temporally possible. All the usual hardening aspects of creating a production-ready feature.&lt;/p&gt;

&lt;p&gt;But once I had the chance to use DIY routing in the field, I found it &lt;em&gt;fun&lt;/em&gt;. Tapping through a couple screens, choosing my trains, switching up my departure times on the fly; I felt like I was in full control.&lt;/p&gt;

&lt;p&gt;It was an obvious next step to add Live Activities and Dynamic Island support (which presented their own implementation challenges). Once these were implemented, DIY routes felt even more like the logical jumping off point for several other features that continued to improve the experience of riding trains.&lt;/p&gt;

&lt;p&gt;The last complementary feature I added before taking a breather was share cards. I found myself often screenshotting and cropping the route bar after I’d created a DIY route and sending it via messaging apps to my friends to tell them when I’d arrive to meet them. So I added a share button and made an attractive little PNG image that’s easy to copy or export to share.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-share-cards.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;A sampling of various DIY route share cards from the Eki Bright marketing images&quot; title=&quot;A sampling of various DIY route share cards from the Eki Bright marketing images&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A sampling of various DIY route share cards from the Eki Bright marketing images&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;From my past product experience, having some sort of shareable content is a surefire way to increase interest in your app. For a train timetables app, external sharing is a tough proposition. But hopefully these share cards will help spread the word assuming I can get enough users to the bottom of that long funnel.&lt;/p&gt;

&lt;h2 id=&quot;how-to-convinceteach-people-to-tryuse-diy-routing&quot;&gt;How to convince/teach people to try/use DIY routing&lt;/h2&gt;

&lt;p&gt;After developing this feature from scratch and using it for a few months, I’m sold. I think DIY routing is great and I use it for 90% of my trips around Tokyo.&lt;/p&gt;

&lt;p&gt;But I’ll admit I haven’t figured out a way to convince people to try using DIY routing in Eki Bright. This blog post is a way to get my thoughts and arguments in order.&lt;/p&gt;

&lt;p&gt;I spent a couple weeks gently polishing the UX and adding the Live Activities feature in order to make the effort of making a DIY route better rewarded. But now I need to actually convince users to try it, and also effectively teach them how to use it.&lt;/p&gt;

&lt;p&gt;Is the most effective teaching method tooltips? An interactive onboarding? A video tutorial? All of the above? This will be a future task.&lt;/p&gt;
</description>
        <pubDate>Fri, 24 Jan 2025 10:43:00 -0600</pubDate>
        <link>https://twocentstudios.com/2025/01/24/eki-bright-the-case-for-diy-routing/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2025/01/24/eki-bright-the-case-for-diy-routing/</guid>
        
        <category>ekibright</category>
        
        
      </item>
    
      <item>
        <title>Core Location Modern API Tips</title>
        <description>&lt;p&gt;The Core Location framework for Apple platforms received some fresh API updates alongside even more permissions minutia in iOS 17 and iOS 18.&lt;/p&gt;

&lt;p&gt;In this post I’ll list as many gotchas as I’ve found in the “modern” Core Location as of iOS 18.1 while developing my train timetables app &lt;a href=&quot;/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright&lt;/a&gt;. Some documented, some not. This is not a quick start or tutorial, but you may want to skim it if you’re thinking about using an iOS 17+ Core Location API so you know what to look out for.&lt;/p&gt;

&lt;p&gt;I’ll be discussing iOS usage of Core Location exclusively (not macOS, visionOS, watchOS).&lt;/p&gt;

&lt;h1 id=&quot;overall-recommendations&quot;&gt;Overall recommendations&lt;/h1&gt;

&lt;h2 id=&quot;prefer-cllocationmanager-over-clmonitor-and-cllocationupdate&quot;&gt;Prefer &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManager&lt;/code&gt; over &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt;&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManager&lt;/code&gt; has been around since the beginning of iPhone OS. Its delegate-based API can feel a bit cumbersome in the current era, but overall, I would still recommend creating your own wrapper over &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManager&lt;/code&gt; if the core competency of your app is even adjacent to location services.&lt;/p&gt;

&lt;p&gt;As far as I can tell, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; are both wrappers themselves over &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManager&lt;/code&gt; albeit with fewer options, fewer capabilities, and many more gotchas spread across iOS minor versions.&lt;/p&gt;

&lt;p&gt;If you’d still like to try them, please read my observations below.&lt;/p&gt;

&lt;h2 id=&quot;prefer-clservicesession-if-your-deployment-target-is-ios-180&quot;&gt;Prefer &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; if your deployment target is iOS 18.0+&lt;/h2&gt;

&lt;p&gt;In my testing, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; has worked as advertised and requires less babysitting than the older imperative location permission APIs.&lt;/p&gt;

&lt;p&gt;Location services permissions is still ripe with complexity and edge cases, so I recommend reading all the documentation and my observations below.&lt;/p&gt;

&lt;h1 id=&quot;official-documentation&quot;&gt;Official documentation&lt;/h1&gt;

&lt;p&gt;I’ll start by listing the documentation for the iOS 17+ APIs I’ve found useful.&lt;/p&gt;

&lt;h2 id=&quot;wwdc-videos&quot;&gt;WWDC videos&lt;/h2&gt;

&lt;p&gt;There are three videos from WWDC 2023 and 2024 from the Core Location team introducing iOS 17 and iOS 18 changes.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2023/10180&quot;&gt;Discover streamlined location updates (2023)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2023/10147&quot;&gt;Meet Core Location Monitor (2023)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2024/10212&quot;&gt;What’s new in location authorization (2024)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These videos do a nice job of explaining the rationale behind the new APIs. They also illustrate the intended usage pretty well for extremely simple use cases.&lt;/p&gt;

&lt;h2 id=&quot;sample-projects&quot;&gt;Sample projects&lt;/h2&gt;

&lt;p&gt;The sample projects, although very freshly updated, do a poor job of actually proving the capabilities of the framework work as advertised. They’re more useful in seeing how the API designers intend the framework user to compose all the pieces together.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/corelocation/adopting-live-updates-in-core-location&quot;&gt;Adopting live updates in Core Location&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/corelocation/monitoring-location-changes-with-core-location&quot;&gt;Monitoring location changes with Core Location&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;warning-about-the-documentation&quot;&gt;Warning about the documentation&lt;/h2&gt;

&lt;p&gt;Some official articles have been written or rewritten assuming your app’s base deployment is iOS 17 or iOS 18. &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/suspending-authorization-requests&quot;&gt;Suspending authorization requests&lt;/a&gt; shows only permission requests based on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;…while some articles have not been updated. &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services&quot;&gt;Requesting authorization to use location services&lt;/a&gt; does not mention &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;Some newer API’s documentation pages are missing important notes that exist in their deprecated counterpart API’s pages. For example, &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/clmonitor-2r51v&quot;&gt;CLMonitor&lt;/a&gt; and &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/cllocationmanager/startmonitoring(for:)&quot;&gt;startMonitoring(for:)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Be sure to check the individual pages under &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/deprecated-symbols&quot;&gt;Deprecated symbols&lt;/a&gt; as well.&lt;/p&gt;

&lt;h1 id=&quot;tips&quot;&gt;Tips&lt;/h1&gt;

&lt;p&gt;This section is an unstructured brain dump of everything I’ve run into while using the iOS 17+ Core Location APIs. I’ve divided up the subsections into:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Permissions (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;Background operation&lt;/li&gt;
  &lt;li&gt;Location updates firehose (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;Location monitoring (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;permissions&quot;&gt;Permissions&lt;/h2&gt;

&lt;p&gt;I recommend starting by reading &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services&quot;&gt;Requesting authorization to use location services&lt;/a&gt; carefully, and watching &lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2024/10212&quot;&gt;What’s new in location authorization&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;implicit-vs-explicit-clservicesession-usage&quot;&gt;Implicit vs. explicit CLServiceSession usage&lt;/h3&gt;

&lt;p&gt;In iOS 18+, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; allow implicit usage of permissions via the underlying &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; mechanism. If you’re using iOS 17 or below, or using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManager&lt;/code&gt; you can skip this part.&lt;/p&gt;

&lt;p&gt;As a pseudo flowchart:&lt;/p&gt;

&lt;p&gt;If:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;You’re only using either &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; (not &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManager&lt;/code&gt;) &lt;em&gt;and&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;You’re supporting iOS 18+ &lt;em&gt;and&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;You only need &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;whenInUse&lt;/code&gt; authorization without explicit full accuracy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then you have 2 options:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Use implicit authorization: simply call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; and Core Location will take a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; (the permissions mechanism) for you behind the scenes.&lt;/li&gt;
  &lt;li&gt;Use explicit authorization: add the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSLocationRequireExplicitServiceSession&lt;/code&gt; key to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Info.plist&lt;/code&gt; and hold an instance of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; for as long as you’re getting values from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;You’re only using either &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; (not &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManager&lt;/code&gt;) &lt;em&gt;and&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;You’re supporting iOS 18+ &lt;em&gt;and&lt;/em&gt;&lt;/li&gt;
  &lt;li&gt;You need &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;always&lt;/code&gt; authorization &lt;em&gt;or&lt;/em&gt; explicit full accuracy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then:&lt;/p&gt;

&lt;p&gt;You must take a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt;. You can still add &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSLocationRequireExplicitServiceSession&lt;/code&gt; if you want to ensure you don’t make a mistake.&lt;/p&gt;

&lt;h3 id=&quot;testing-the-full-accuracy-permission-prompt&quot;&gt;Testing the full accuracy permission prompt&lt;/h3&gt;

&lt;p&gt;One way to test the permission prompt for full accuracy usage is:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Start a fresh copy of your app on a simulator.&lt;/li&gt;
  &lt;li&gt;Trigger the location prompt and allow &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;whenInUse&lt;/code&gt; permissions.&lt;/li&gt;
  &lt;li&gt;Force quit the app.&lt;/li&gt;
  &lt;li&gt;In the simulator’s Settings.app, go to your app’s settings page -&amp;gt; Location Services and disable Full Accuracy.&lt;/li&gt;
  &lt;li&gt;Cold launch your app.&lt;/li&gt;
  &lt;li&gt;Trigger your feature that requires full accuracy permissions.&lt;/li&gt;
  &lt;li&gt;The permission prompt should appear.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/core-location-full-accuracy-prompt.jpg&quot; width=&quot;&quot; height=&quot;380&quot; alt=&quot;An example of the temporary full accuracy permission prompt&quot; title=&quot;An example of the temporary full accuracy permission prompt&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;An example of the temporary full accuracy permission prompt&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;If you approve this permission prompt it will still appear every time you trigger your feature in future cold launches (it’s “temporary” after all).&lt;/p&gt;

&lt;h3 id=&quot;localizing-fullaccuracypurposekey&quot;&gt;Localizing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fullAccuracyPurposeKey&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;Full accuracy requests are available in two API:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/corelocation/clservicesession-pt7n/init(authorization:fullaccuracypurposekey:)&quot;&gt;init(authorization:fullAccuracyPurposeKey:)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/corelocation/cllocationmanager/requesttemporaryfullaccuracyauthorization(withpurposekey:completion:)&quot;&gt;requestTemporaryFullAccuracyAuthorization(withPurposeKey:completion:)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(This is another one of those cases where all the documentation is on the page of the deprecated API.)&lt;/p&gt;

&lt;p&gt;I’m using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; in the following manner:&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;kt&quot;&gt;CLServiceSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;authorization&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;whenInUse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;fullAccuracyPurposeKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;NSLocationTemporaryUsageDescriptionDictionaryMonitor&quot;&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;Specifying &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fullAccuracyPurposeKey&lt;/code&gt; tells &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; that the feature associated with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; instance prefers having &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fullAccuracy&lt;/code&gt;, and will try to prompt for it automatically when possible (“possible” being any number of rules).&lt;/p&gt;

&lt;p&gt;The relevant part of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Info.plist&lt;/code&gt; should look like the below plist for specifying your app’s reason for wanting full accuracy permission. Notice I have two different keys because I have two features that each instantiate their own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;plist&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;version=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;1.0&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
	&lt;span class=&quot;nt&quot;&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSLocationTemporaryUsageDescriptionDictionary&lt;span class=&quot;nt&quot;&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
	&lt;span class=&quot;nt&quot;&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
		&lt;span class=&quot;nt&quot;&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSLocationTemporaryUsageDescriptionDictionaryMonitor&lt;span class=&quot;nt&quot;&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
		&lt;span class=&quot;nt&quot;&gt;&amp;lt;string&amp;gt;&lt;/span&gt;NSLocationTemporaryUsageDescriptionDictionaryMonitor&lt;span class=&quot;nt&quot;&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
		&lt;span class=&quot;nt&quot;&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSLocationTemporaryUsageDescriptionDictionaryNearbyStations&lt;span class=&quot;nt&quot;&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
		&lt;span class=&quot;nt&quot;&gt;&amp;lt;string&amp;gt;&lt;/span&gt;NSLocationTemporaryUsageDescriptionDictionaryNearbyStations&lt;span class=&quot;nt&quot;&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
	&lt;span class=&quot;nt&quot;&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/plist&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;If you’re not localizing and have no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InfoPlist.xcstrings&lt;/code&gt;, you can add the actual message you show the user to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;string&amp;gt;your message&amp;lt;/string&amp;gt;&lt;/code&gt; part.&lt;/p&gt;

&lt;p&gt;If you are localizing, then you should add &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSLocationTemporaryUsageDescriptionDictionaryMonitor&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSLocationTemporaryUsageDescriptionDictionaryNearbyStations&lt;/code&gt; as keys in your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InfoPlist.xcstrings&lt;/code&gt; file, with the corresponding translations.&lt;/p&gt;

&lt;p&gt;In the above plist I’ve repeated the key name as the value, but it won’t be used since I added the key to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;InfoPlist.xcstrings&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I’ve used a key name with the full prefix of the root dictionary key &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSLocationTemporaryUsageDescriptionDictionary&lt;/code&gt;, but you can use any valid localization key name.&lt;/p&gt;

&lt;p&gt;The setup is documented &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/cllocationmanager/requesttemporaryfullaccuracyauthorization(withpurposekey:completion:)&quot;&gt;in this API&lt;/a&gt; and &lt;a href=&quot;https://developer.apple.com/forums/thread/652801?answerId=624692022#624692022&quot;&gt;in the forums&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;location-updates-in-the-background&quot;&gt;Location updates in the background&lt;/h2&gt;

&lt;p&gt;If you need to run in the background based on location changes, you have a few requirements and a few options to consider.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;You must to add &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Background Modes -&amp;gt; Location updates&lt;/code&gt; to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Signing &amp;amp; Capabilities&lt;/code&gt; section of your app target.&lt;/li&gt;
  &lt;li&gt;You must still add the proper permissions key (probably &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSLocationWhenInUseUsageDescription&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NSLocationAlwaysAndWhenInUseUsageDescription&lt;/code&gt;).&lt;/li&gt;
  &lt;li&gt;You must have either &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.whenInUse&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.always&lt;/code&gt; permission. As far as I know, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fullAccuracy&lt;/code&gt; permission is not required but I imagine the lack of it would affect most features that require background location updates.&lt;/li&gt;
  &lt;li&gt;You must either:
    &lt;ul&gt;
      &lt;li&gt;Run a Live Activity &lt;em&gt;or&lt;/em&gt;&lt;/li&gt;
      &lt;li&gt;Create and hold an instance of &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/clbackgroundactivitysession-3mzv3&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLBackgroundActivitySession&lt;/code&gt;&lt;/a&gt; (which is conceptually a single-purpose pre-configured Live Activity).&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;You must^ be subscribed to an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AsyncStream&lt;/code&gt; from either a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdates&lt;/code&gt; of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; instance. (^I have not tested how the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManager&lt;/code&gt; APIs work with iOS 17+ location background APIs, so you are on your own verifying how they work.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I want to specifically call out that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.always&lt;/code&gt; permission is &lt;em&gt;not&lt;/em&gt; required to receive location updates in the background assuming the above requirements are satisfied. As discussed in &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services&quot;&gt;this article&lt;/a&gt;, the main difference between &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;whenInUse&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;always&lt;/code&gt; permission is that:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;With &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;always&lt;/code&gt; permission, your app has the chance of being cold launched in the background in response to “significant location change, visits, and region monitoring services” if it was previously terminated.&lt;/li&gt;
  &lt;li&gt;With &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;whenInUse&lt;/code&gt; permission, if your app is terminated for any reason, the user must open it again before location updates may be received in the background.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: there’s something called a &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/creating-a-location-push-service-extension&quot;&gt;Location push service extension&lt;/a&gt; that requires &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.always&lt;/code&gt; permission, but I have no experience with what the other requirements are for this feature.&lt;/p&gt;

&lt;p&gt;Relevant docs:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/corelocation/handling-location-updates-in-the-background&quot;&gt;Handling location updates in the background&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2023/10180&quot;&gt;Discover streamlined location updates&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services&quot;&gt;Requesting authorization to use location services&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;the-location-updates-firehose-cllocationupdate&quot;&gt;The location updates firehose (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt;)&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate.liveUpdates&lt;/code&gt; returns a stream of both location coordinates, “errors”, and permissions issues in the form of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; struct.&lt;/p&gt;

&lt;p&gt;Although in theory the API is more streamlined for the simplest of use cases, I’d generally still recommend creating your own system around &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManager&lt;/code&gt; if you’re doing anything that requires stability, robustness, or reliability with Core Location. Regardless, some usage notes for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; are below.&lt;/p&gt;

&lt;h3 id=&quot;ios-18-recommended&quot;&gt;iOS 18+ recommended&lt;/h3&gt;

&lt;p&gt;Although &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; was introduced in iOS 17, I don’t recommend using it until iOS 18 for the following reasons:&lt;/p&gt;

&lt;p&gt;In my testing, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate.liveUpdates&lt;/code&gt; will return no results on iOS 17 when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fullAccuracy&lt;/code&gt; permission is denied. I have no idea whether this was related to the permissions system and fixed in iOS 18 alongside &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; or whether it was simply a bug, but iOS 18 has the expected behavior of returning less accurate &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; results when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fullAccuracy&lt;/code&gt; is denied by the user.&lt;/p&gt;

&lt;p&gt;According to the WWDC video, when background usage is not requested by the app, Core Location handles automatically disabling &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate.liveUpdates&lt;/code&gt; when going to the background and re-enabling it when coming back into the foreground, but only in iOS 18 alongside &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt;. I can’t say for sure how it works in iOS 17, only that my view layer was handling this manually to make sure there were no issues.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; does not include any properties for permissions or other errors in iOS 17 (e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;authorizationDenied&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;locationUnavailable&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Not a huge issue by any means, but &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate.isStationary&lt;/code&gt; was introduced in iOS 17 and deprecated in iOS 18 and renamed to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate.stationary&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;stationary-is-rarely-set-when-the-app-is-in-the-foreground&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stationary&lt;/code&gt; is rarely set (when the app is in the foreground?)&lt;/h3&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stationary&lt;/code&gt; flag is set “on the last update before updates are paused because the device has stopped moving” according to the &lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2024/10212?time=1003&quot;&gt;WWDC video&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What “stopped moving” means is not explicitly documented.&lt;/p&gt;

&lt;p&gt;In practice I’ve never seen an update with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stationary&lt;/code&gt; flag set to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;true&lt;/code&gt;. Based on hints from the WWDC videos, my hypothesis is that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stationary&lt;/code&gt; is most relevant when using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; and your app is in the background. Perhaps in that setting, Core Location will offer fewer updates and set the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stationary&lt;/code&gt; flag more liberally.&lt;/p&gt;

&lt;h3 id=&quot;cllocationupdate-has-no-concept-of-filtering&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; has no concept of filtering&lt;/h3&gt;

&lt;p&gt;The “old” API &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManager&lt;/code&gt; has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;distanceFilter&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;desiredAccuracy&lt;/code&gt; you can use to have Core Location filter updates on your behalf.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; does not have these options. You have to do filtering on the stream yourself.&lt;/p&gt;

&lt;p&gt;Perhaps the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate.LiveConfiguration&lt;/code&gt; values are supposed to influence this instead.&lt;/p&gt;

&lt;h3 id=&quot;cllocationupdatelocationunavailable-is-unpredictable&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate.locationUnavailable&lt;/code&gt; is unpredictable&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;locationUnavailable&lt;/code&gt; was introduced in iOS 18. Previously, a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; could only have a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;location == nil&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I expected &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;locationUnavailable&lt;/code&gt; to be useful as a way to change my UI and alert my users that there may be a temporary issue with getting their location.&lt;/p&gt;

&lt;p&gt;In practice, the behavior changed in iOS 18.1 and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;locationUnavailable&lt;/code&gt; updates would be returned in quick succession and interspersed with normal location updates under what I’d consider ideal device conditions. It caused my UI to flicker in distressing ways (nod to SwiftUI) and was unpredictable enough to be hard to filter manually.&lt;/p&gt;

&lt;p&gt;For now, I’ve started ignoring &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;locationUnavailable&lt;/code&gt; updates completely. I’ll probably revisit it again in iOS 19 to see whether it’s stable enough to positively influence the UX.&lt;/p&gt;

&lt;h3 id=&quot;updates-are-returned-about-1-or-2-times-per-second&quot;&gt;Updates are returned about 1 or 2 times per second&lt;/h3&gt;

&lt;p&gt;I haven’t seen any official documentation about the update interval from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate.liveUpdates&lt;/code&gt;. In practice I usually see updates on average of about 1 or 2 per second while in the foreground. It’s similar on device and on the simulator. Just an FYI.&lt;/p&gt;

&lt;h3 id=&quot;background-behavior-of-cllocationupdate&quot;&gt;Background behavior of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;I don’t (yet) have a feature that uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; in the background so my testing has been light. But I can report that I’ve seen &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; send results while in the background as long as the app has been configured properly for it (see the above “Location updates in the background” section).&lt;/p&gt;

&lt;h2 id=&quot;location-monitoring-clmonitor&quot;&gt;Location monitoring (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt;)&lt;/h2&gt;

&lt;h3 id=&quot;documented-limitations&quot;&gt;Documented limitations&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/corelocation/cllocationmanager/startmonitoring(for:)&quot;&gt;Source&lt;/a&gt; for most of the below quotes:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;An app can register up to 20 regions at a time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From my testing in iOS 18.1, if you add more than 20, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; will emit one event for each condition over the limit with state &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor.Event.State.unmonitored&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;According to the WWDC video, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor.Event.conditionLimitExceeded&lt;/code&gt; should also be set in this case, although I haven’t confirmed this.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;The region monitoring service requires network connectivity.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;However, a note from &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/cllocationmanager/startmonitoringsignificantlocationchanges()&quot;&gt;startMonitoringSignificantLocationChanges&lt;/a&gt; says that “If the device is able to retrieve data from the network, the location manager is much more likely to deliver notifications in a timely manner.”&lt;/p&gt;

&lt;p&gt;So maybe network connectivity isn’t always required?&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;an app can expect to receive the appropriate region entered or region exited notification within 3 to 5 minutes on average, if not sooner.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is more or less what I’ve experienced in my testing, with the simulator reporting slightly less on average than the device. It makes testing difficult and also makes it difficult to ensure my feature reacts predictably in the background.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;In iOS 6, regions with a radius between 1 and 400 meters work better on iPhone 4S or later devices.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Since this hasn’t been updated since iOS 6, the only other advice I’ve found was in &lt;a href=&quot;https://developer.apple.com/forums/thread/757363?answerId=791471022#791471022&quot;&gt;this forums thread&lt;/a&gt; where an Apple engineer says to be careful about making the regions too small:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Also, I wonder if your regions are appropriately large. If you are getting significant location updates every 5 miles, that means you are in an area where the mobile/wifi based signal coverage (which these services depend on) is only adequate for that kind of accuracy. If your region radii are smaller than what the horizontalAccuracy the significant location updates provide, you may actually miss the entry or exit events to those smaller regions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The above quote broadly references a note buried in the &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/cllocationmanager/startmonitoringsignificantlocationchanges()&quot;&gt;startMonitoringSignificantLocationChanges&lt;/a&gt; documentation:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Apps can expect a notification as soon as the device moves 500 meters or more from its previous notification.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;clmonitor-on-the-ios-simulator&quot;&gt;CLMonitor on the iOS simulator&lt;/h3&gt;

&lt;p&gt;I had varying success using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; on the iOS 18.1 simulator alongside active location simulation using a GPX file (discussed later). It was a flakey enough that I’d recommend using a real device, although using one was only slightly more successful in verifying &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; usage.&lt;/p&gt;

&lt;h3 id=&quot;clmonitoreventstate-values&quot;&gt;CLMonitor.Event.State values&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://developer.apple.com/documentation/corelocation/clmonitor-2r51v/event/state-swift.typealias&quot;&gt;CLMonitor.Event.State&lt;/a&gt; is an alias for the undocumented &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;__CLMonitoringState&lt;/code&gt;. I can only access the values occasionally via autocomplete:&lt;/p&gt;

&lt;p&gt;My understanding of the 4 states is:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unknown&lt;/code&gt;: the initial state of the condition unless otherwise specified at the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;add&lt;/code&gt; callsite.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unmonitored&lt;/code&gt;: I believe this is only used when there are too many conditions (over the 20 limit) and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; is reporting which conditions will not be monitored.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unsatisfied&lt;/code&gt;: the device is outside the condition region.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;satisfied&lt;/code&gt;: the device is inside the condition region.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;how-to-set-up-clmonitor&quot;&gt;How to set up &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;When using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; (on iOS 18+) you need to manage the lifetime of multiple objects:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; for ensuring Core Location knows your permission goals.&lt;/li&gt;
  &lt;li&gt;A named &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; instance for registering conditions.&lt;/li&gt;
  &lt;li&gt;A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Task&lt;/code&gt; that awaits &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;await monitor.events&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your requirements are simple – for example, you’re only monitoring a static condition – you can do all this setup in one place and then tear everything down when cancelling the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;monitor.events&lt;/code&gt; stream.&lt;/p&gt;

&lt;p&gt;My requirements are more complicated. The user can modify their route at any time, which triggers a full update of which conditions are monitored. The route may be discarded, at which point I need to stop all monitoring completely.&lt;/p&gt;

&lt;p&gt;If the conditions you need to monitor change unpredictably, I recommend the following pseudocode when changing monitored conditions:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;If there are no more conditions to monitor:
    &lt;ul&gt;
      &lt;li&gt;Cancel any existing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Task&lt;/code&gt; you have monitoring events already.&lt;/li&gt;
      &lt;li&gt;If a monitor exists, remove all conditions from it, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nil&lt;/code&gt; it out.&lt;/li&gt;
      &lt;li&gt;Call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;invalidate&lt;/code&gt; and  &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nil&lt;/code&gt; out the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLBackgroundActivitySession&lt;/code&gt; if it exists.&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nil&lt;/code&gt; out the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; if it exists.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;Otherwise:
    &lt;ul&gt;
      &lt;li&gt;Create a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; if one does not already exist.&lt;/li&gt;
      &lt;li&gt;Create a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLBackgroundActivitySession&lt;/code&gt; if one does not already exist and you want monitoring to keep your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;whenInUse&lt;/code&gt; authorized app alive in the background without a dedicated Live Activity.&lt;/li&gt;
      &lt;li&gt;Create a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; with a static name if one does not already exist.&lt;/li&gt;
      &lt;li&gt;Calculate which identifiers you need to add and which ones you need to remove (or simpler: remove all conditions and add all new ones)&lt;/li&gt;
      &lt;li&gt;Remove unused conditions&lt;/li&gt;
      &lt;li&gt;Add new conditions&lt;/li&gt;
      &lt;li&gt;If a monitoring &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Task&lt;/code&gt; exists, do nothing.&lt;/li&gt;
      &lt;li&gt;Otherwise, start a new &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Task&lt;/code&gt; awaiting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor.events&lt;/code&gt; and save a reference to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Task&lt;/code&gt;.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;tips-for-creating-a-system-around-clmonitor&quot;&gt;Tips for creating a system around CLMonitor&lt;/h3&gt;

&lt;h4 id=&quot;you-should-target-ios-18&quot;&gt;You should target iOS 18+&lt;/h4&gt;

&lt;p&gt;You can technically use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; with iOS 17, but handling permissions will be either be more complicated or less robust without &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; (iOS 18+).&lt;/p&gt;

&lt;h4 id=&quot;you-need-to-keep-a-reference-to-clservicesession-clmonitor-and-the-task-that-contains-your-monitoring&quot;&gt;You &lt;em&gt;need&lt;/em&gt; to keep a reference to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt;, and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Task&lt;/code&gt; that contains your monitoring&lt;/h4&gt;

&lt;p&gt;This is because:&lt;/p&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; defines the authorization requirements of your use of location services for the lifetime of your feature that uses them.&lt;/p&gt;

&lt;p&gt;It is dangerous to try to “recover” a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; with the same name via the initializer if you lose the reference. This means that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; is not designed to be cheaply created and discarded. I tried this strategy at first: create a monitor and discard the old one each time my conditions changed. However, the internal bookkeeping done by Core Location means that the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; may outlive your expectations. This means that if you try to initialize a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; with the same name too soon after you’ve discarded one, the app will crash with:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Assertion failure in +[CLMonitor _requestMonitorWithConfiguration:locationManager:completion:], CLMonitor.mm:517
Terminating app due to uncaught exception &apos;NSInternalInconsistencyException&apos;, reason: &apos;Monitor named myMonitor is already in use&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;From my testing, it seems best to subscribe to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor.events&lt;/code&gt; once and only once per instance of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; over the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt;’s entire lifetime. Let me try to explain this thoroughly.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;You have a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;You add some conditions to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;You subscribe to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor.events&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Now you want to change the conditions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this point, you should keep the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; and the subscription to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor.events&lt;/code&gt; alive and call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor.add&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor.remove&lt;/code&gt; as necessary.&lt;/p&gt;

&lt;p&gt;The other reason it’s “better” to do diffing and only add/remove conditions as necessary is because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; keeps some state on your behalf as illustrated by the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor.record(for:)&lt;/code&gt; API that returns a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor.Record.lastEvent&lt;/code&gt;.&lt;/p&gt;

&lt;h4 id=&quot;do-not-treat-clmonitor-as-cheaply-disposable&quot;&gt;Do not treat &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; as cheaply disposable&lt;/h4&gt;

&lt;p&gt;You &lt;em&gt;should not&lt;/em&gt; discard the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; and immediately create a new one with the same name (crash described above) and you &lt;em&gt;should not&lt;/em&gt; cancel the subscription and create a new subscription.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; has a bug (I presume) where any later subscription attempt to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor.events&lt;/code&gt; after the first has been cancelled will itself cancel and fall through immediately.&lt;/p&gt;

&lt;h4 id=&quot;do-not-subscribe-to-clmonitor-multiple-times-simultaneously&quot;&gt;Do not subscribe to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; multiple times simultaneously&lt;/h4&gt;

&lt;p&gt;You &lt;em&gt;should not&lt;/em&gt; try to subscribe to the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; multiple times for whatever reason you might want to do that. In my testing, events will be pushed out randomly between subscriptions. This may be a standard &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AsyncStream&lt;/code&gt; behavior, but regardless, I can’t think of a reason why you’d want this behavior unless you were building some sort of system of distributed workers.&lt;/p&gt;

&lt;h4 id=&quot;you-may-tear-down-the-system-and-stop-monitoring-completely-while-still-being-able-to-recreate-the-system-later&quot;&gt;You &lt;em&gt;may&lt;/em&gt; tear down the system and stop monitoring completely, while still being able to recreate the system later&lt;/h4&gt;

&lt;p&gt;OK, so what about the situation where you &lt;em&gt;do&lt;/em&gt; want to completely stop monitoring but also retain the ability to start monitoring again later?&lt;/p&gt;

&lt;p&gt;The “stop monitoring for now” situation should be covered by the above pseudocode. As long as you do the proper cleanup of cancelling the subscription, removing conditions from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt;, and removing your reference to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLServiceSession&lt;/code&gt; you should be set up fine to recreate the entire system at some later point (but &lt;em&gt;not&lt;/em&gt; right away, with “right away” meaning at least in the same run loop).&lt;/p&gt;

&lt;h4 id=&quot;define-your-system-as-an-actor&quot;&gt;Define your system as an actor&lt;/h4&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; is an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;actor&lt;/code&gt;, which means that basically every one of its APIs requires an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;await&lt;/code&gt;. I found it more idiomatic and convenient to define my wrapper system as an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;actor&lt;/code&gt;.&lt;/p&gt;

&lt;h4 id=&quot;reinitializing-the-system-in-a-terminatedcold-launch-scenario&quot;&gt;Reinitializing the system in a terminated/cold launch scenario&lt;/h4&gt;

&lt;p&gt;There are specific requirements around reinitializing a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; system after the app has been terminated. My use case doesn’t require this functionality, and therefore my implementation does not handle it. If you do need to handle it (and have &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.always&lt;/code&gt; authorization), I encourage you to read &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/handling-location-updates-in-the-background&quot;&gt;the docs&lt;/a&gt;, &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/monitoring-location-changes-with-core-location&quot;&gt;sample code&lt;/a&gt;, and watch &lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2023/10147/&quot;&gt;the WWDC video&lt;/a&gt;.&lt;/p&gt;

&lt;h4 id=&quot;be-careful-about-running-multiple-clmonitor-instances-simultaneously&quot;&gt;Be careful about running multiple &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; instances simultaneously&lt;/h4&gt;

&lt;p&gt;In theory, it should be fine to create multiple &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; instances within the same app session. However, the “up to 20 conditions” limitation is per-app, so with multiple &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt;s you will need to ensure globally you’re not exceeding that limit (if your use case has a danger of doing so).&lt;/p&gt;

&lt;p&gt;I haven’t tested running multiple &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt;s in parallel, so your milage my vary.&lt;/p&gt;

&lt;h3 id=&quot;clmonitor-wrapper-sample-code&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; wrapper sample code&lt;/h3&gt;

&lt;p&gt;Below is a lightly tested implementation of the above pseudocode based on my app’s own implementation. I’d encourage you to use it only as reference when writing your own implementation based on your own app’s requirements and testing it accordingly.&lt;/p&gt;

&lt;p&gt;This implementation assumes you’ve set up the rest of your project correctly with permissions strings, background modes, etc.&lt;/p&gt;

&lt;p&gt;This implementation allows you create an instance of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SampleLocationMonitor&lt;/code&gt; with the same lifetime as your app (read: singleton). Call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;monitor()&lt;/code&gt; each time your set of conditions changes. If the input set of conditions is empty, the instance will go dormant, otherwise it will update the conditions interactively.&lt;/p&gt;

&lt;p&gt;This implementation also supports background operation via &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLBackgroundActivitySession&lt;/code&gt;. You should remove this if you already have a Live Activity tied to the lifetime of your monitoring. Or remove it if you don’t need any updates in the background.&lt;/p&gt;

&lt;p&gt;The missing piece (see “TODO” below) is what you want to do in response to receiving an event. In my case (not shown), I’m simply refreshing a Live Activity based on an existing schedule.&lt;/p&gt;

&lt;p&gt;If you want access to all events, I would be careful about modifying this to return the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;monitor.events&lt;/code&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AsyncStream&lt;/code&gt; directly because of the limitations discussed above, namely: there can only be one subscription per &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLMonitor&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; you cannot cancel a subscription and create a new one later.&lt;/p&gt;

&lt;p&gt;Instead, I’d consider either:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Registering a closure along side each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MonitorCondition&lt;/code&gt; for the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SampleLocationMonitor&lt;/code&gt; to execute (this may be difficult to design due to Swift 6 concurrency isolation).&lt;/li&gt;
  &lt;li&gt;Creating a long-lived &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AsyncStream&lt;/code&gt; as a property of and bound to the lifetime of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SampleLocationMonitor&lt;/code&gt; that relays all events to a subscriber.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I haven’t tested either strategy, so your milage my vary.&lt;/p&gt;

&lt;p&gt;Anyway, here is the sample implementation:&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;MonitorCondition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Identifiable&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;Hashable&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;id&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;coordinates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLLocationCoordinate2D&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;radiusInMeters&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLLocationDistance&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;@available(iOS 18.0, *)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;actor&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;SampleLocationMonitor&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;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;authSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLServiceSession&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;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;monitor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLMonitor&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;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;backgroundSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLBackgroundActivitySession&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;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;monitoringTask&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&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;n&quot;&gt;any&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;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;static&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;monitorID&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;monitor&quot;&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;func&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;monitor&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;monitorConditions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Set&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;MonitorCondition&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&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;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;monitorConditions&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isEmpty&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;n&quot;&gt;monitoringTask&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;cancel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;monitoringTask&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;monitor&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;monitoringIdentifiers&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;identifiers&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;identifier&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitoringIdentifiers&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;remove&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;identifier&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;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;monitor&lt;/span&gt; &lt;span class=&quot;o&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;n&quot;&gt;backgroundSession&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;invalidate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;backgroundSession&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;authSession&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&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;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;authSession&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;authSession&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLServiceSession&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;authorization&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;whenInUse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;fullAccuracyPurposeKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;NSLocationTemporaryUsageDescriptionDictionarySampleLocationMonitor&quot;&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;n&quot;&gt;authSession&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;authSession&lt;/span&gt;
        
        &lt;span class=&quot;n&quot;&gt;backgroundSession&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;backgroundSession&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLBackgroundActivitySession&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;monitor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLMonitor&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;existingMonitor&lt;/span&gt; &lt;span class=&quot;o&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;n&quot;&gt;monitor&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;monitor&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;existingMonitor&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&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;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;newMonitor&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLMonitor&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;n&quot;&gt;monitorID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;monitor&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;newMonitor&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;n&quot;&gt;monitor&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitor&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        
        &lt;span class=&quot;nf&quot;&gt;assert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;monitorConditions&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;CLMonitor supports up to 20 conditions&quot;&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;existingIdentifiers&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;monitor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;identifiers&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;identifiersToAdd&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Set&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;monitorConditions&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&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;subtracting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;existingIdentifiers&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;identifiersToRemove&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Set&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;existingIdentifiers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;subtracting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;monitorConditions&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)))&lt;/span&gt;
        
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;identifierToRemove&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;identifiersToRemove&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;remove&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;identifierToRemove&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;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitorCondition&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitorConditions&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;n&quot;&gt;identifiersToAdd&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;monitorCondition&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&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;continue&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;condition&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLMonitor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;CircularGeographicCondition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;center&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitorCondition&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;coordinates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitorCondition&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;radiusInMeters&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;condition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;identifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitorCondition&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;suming&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;unsatisfied&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;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;monitoringTask&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;nil&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;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;monitoringTask&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Task&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;events&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;c1&quot;&gt;// Optional: the last event if you need to do comparisons to derive _entry_ or _exit_ events.&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;lastEvent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitor&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;record&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;identifier&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;lastEvent&lt;/span&gt;

                &lt;span class=&quot;c1&quot;&gt;// TODO: Do whatever you want to do with the events here&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;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;monitoringTask&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;monitoringTask&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;h2 id=&quot;testing&quot;&gt;Testing&lt;/h2&gt;

&lt;h3 id=&quot;simulating-a-moving-location&quot;&gt;Simulating a moving location&lt;/h3&gt;

&lt;p&gt;I had some success using simulated location changes via GPX files. It works on both simulator and device.&lt;/p&gt;

&lt;p&gt;The GPX file playback starts immediately on app launch. The file playback will repeat immediately after reaching the last entry.&lt;/p&gt;

&lt;p&gt;I used this tutorial: &lt;a href=&quot;https://digitalbunker.dev/simulating-a-moving-location-in-ios/&quot;&gt;Simulating A Moving Location In iOS&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;fixing-issue-where-gpx-files-cannot-be-selected-in-the-file-picker-in-xcode&quot;&gt;Fixing issue where GPX files cannot be selected in the file picker in Xcode&lt;/h3&gt;

&lt;p&gt;A strange issue blocked me from using location simulation at first.&lt;/p&gt;

&lt;p&gt;In the file picker that appears when selecting “Add GPS Exchange to Project” in the Scheme editor, all GPX files would be greyed out and unselectable. The issue appeared in a few random Stack Overflow and forum posts scattered across several years.&lt;/p&gt;

&lt;p&gt;Eventually I tracked it down to an app I had installed called Guitar Pro asserting ownership over &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gpx&lt;/code&gt; files in macOS system wide. I confirmed this with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mdls&lt;/code&gt; CLI utility (output abridged for clarity):&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;gp&quot;&gt;$ &lt;/span&gt;mdls basha-yoko.gpx

_kMDItemDisplayNameWithExtensions  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;basha-yoko.gpx&quot;&lt;/span&gt;
kMDItemContentCreationDate         &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; 2024-11-30 03:31:08 +0000
kMDItemContentType                 &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;com.arobas-music.guitarpro6.document&quot;&lt;/span&gt;
kMDItemContentTypeTree             &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;s2&quot;&gt;&quot;com.arobas-music.guitarpro6.document&quot;&lt;/span&gt;,
    &lt;span class=&quot;s2&quot;&gt;&quot;public.data&quot;&lt;/span&gt;,
    &lt;span class=&quot;s2&quot;&gt;&quot;public.item&quot;&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt;
kMDItemDisplayName                 &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;basha-yoko.gpx&quot;&lt;/span&gt;
kMDItemDocumentIdentifier          &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; 57051
kMDItemKind                        &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Guitar Pro 6 document&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Uninstalling Guitar Pro was the only thing that fixed it long enough for me to select one file. After I restarted my computer, the file picker was broken again.&lt;/p&gt;

&lt;p&gt;For anyone else suffering with this issue, you may be able to fix it by opening your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.xcscheme&lt;/code&gt; file (sometimes embedded in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.xcodeproj&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.xcodeworkspace&lt;/code&gt; bundle) and adding the following xml when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;locations.gpx&lt;/code&gt; is in the same folder as your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.xcodeproj&lt;/code&gt;. Basically, the file path of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;identifier&lt;/code&gt; is in reference to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.xcscheme&lt;/code&gt; file, which in my case is two folders deep inside the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.xcodeproj&lt;/code&gt; bundle.&lt;/p&gt;

&lt;div class=&quot;language-xml highlighter-rouge&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;LocationScenarioReference&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;identifier =&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;../../locations.gpx&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;referenceType =&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;0&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/LocationScenarioReference&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;The only way to tell if it’s working is by running it on the simulator and seeing it the location updates are played back as you’d expect. For me, Xcode still wouldn’t show the GPX file as active in its UI.&lt;/p&gt;

&lt;p&gt;I don’t think your GPX file needs to be added to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.xcodeproj&lt;/code&gt; but I’m not 100% sure.&lt;/p&gt;

&lt;p&gt;Reference: &lt;a href=&quot;https://forums.developer.apple.com/forums/thread/686875&quot;&gt;Apple forums&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;I hope this post will help those in the Core Location avant-garde.&lt;/p&gt;

&lt;p&gt;Core Location is an important part of my app, but I still have many other features to manage, so although I’ll try to update this post with any new behavior I discover, I also welcome any well-researched tips or links to related blog posts. Feel free to send them over.&lt;/p&gt;
</description>
        <pubDate>Mon, 02 Dec 2024 16:20:00 -0600</pubDate>
        <link>https://twocentstudios.com/2024/12/02/core-location-modern-api-tips/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2024/12/02/core-location-modern-api-tips/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>ekibright</category>
        
        
      </item>
    
      <item>
        <title>Eki Bright - Design of Station Detail</title>
        <description>&lt;p&gt;I’ve done a few nice-sized releases of &lt;a href=&quot;/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright&lt;/a&gt; since my &lt;a href=&quot;/2024/08/06/eki-bright-developing-the-app-for-ios/&quot;&gt;first launch&lt;/a&gt; in August.&lt;/p&gt;

&lt;p&gt;Eki Bright is my solo-developed iOS app for viewing station timetables for the Tokyo-area train network.&lt;/p&gt;

&lt;p&gt;In this post, I want to share some of the design decisions for a screen I added in v1.2 and v1.3: the &lt;strong&gt;station detail screen&lt;/strong&gt;.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-current.jpg&quot; width=&quot;&quot; height=&quot;600&quot; alt=&quot;The station detail screen, added in v1.2&quot; title=&quot;The station detail screen, added in v1.2&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The station detail screen, added in v1.2&lt;/div&gt;&lt;/div&gt;

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

&lt;p&gt;The first release of Eki Bright had 4 screens: home, station timetable, train timetable, and station search (disregarding the about screen, nearby screen, and widgets).&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-app-v1.png&quot; width=&quot;&quot; height=&quot;380&quot; alt=&quot;The four screens available in v1.0&quot; title=&quot;The four screens available in v1.0&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The four screens available in v1.0&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In all honesty, even the “nearby stations” section of the home screen and the train timetables screens were superfluous to my original concept for the app. Even station search by text was only required to populate your station bookmarks.&lt;/p&gt;

&lt;p&gt;However, as most projects go, the scope started to naturally increase as I found myself out and about using the prototype and missing features.&lt;/p&gt;

&lt;h2 id=&quot;the-problem-with-the-station-timetable-screen&quot;&gt;The problem with the station timetable screen&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;Station&lt;/strong&gt; is the core model concept in the app. A &lt;strong&gt;Railway&lt;/strong&gt; connects an ordered group of stations.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-railway-model.png&quot; width=&quot;&quot; height=&quot;320&quot; alt=&quot;A Station belongs to a Railway&quot; title=&quot;A Station belongs to a Railway&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A Station belongs to a Railway&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A &lt;strong&gt;StationTimetable&lt;/strong&gt; shows when a &lt;strong&gt;Train&lt;/strong&gt; departs a &lt;strong&gt;Station&lt;/strong&gt; going in one &lt;strong&gt;Direction&lt;/strong&gt; on a particular &lt;strong&gt;Schedule&lt;/strong&gt;.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-station-timetable-model.png&quot; width=&quot;&quot; height=&quot;370&quot; alt=&quot;A StationTimetable is defined by its Station, Direction, and Schedule&quot; title=&quot;A StationTimetable is defined by its Station, Direction, and Schedule&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A StationTimetable is defined by its Station, Direction, and Schedule&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I already had a simple station timetable screen whose focus was on departure times.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-simple-timetable.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;The simple station timetable screen in v1.0&quot; title=&quot;The simple station timetable screen in v1.0&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The simple station timetable screen in v1.0&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The station timetable screen was built as the logical next step to choosing a &lt;strong&gt;StationBookmark&lt;/strong&gt;. A &lt;strong&gt;StationBookmark&lt;/strong&gt; stores both the &lt;strong&gt;Station&lt;/strong&gt; a user often departs from and the &lt;strong&gt;Direction&lt;/strong&gt; they’re headed. For example, I often depart from &lt;em&gt;Bashamichi&lt;/em&gt; station going &lt;em&gt;inbound&lt;/em&gt; towards Yokohama on a &lt;em&gt;weekday&lt;/em&gt; schedule.&lt;/p&gt;

&lt;p&gt;Without that second piece of data – the &lt;strong&gt;Direction&lt;/strong&gt; – there’s not enough information yet to show a timetable. Not a problem for bookmarks, but a big problem for nearby stations and searched stations.&lt;/p&gt;

&lt;p&gt;For v1.0, my design solution was:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Add a direction switcher to the station timetable screen (when applicable – some stations only have one direction).&lt;/li&gt;
  &lt;li&gt;Default the user to one of the directions when they selected a station from nearby or search.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The assumption was that the user would double check the default direction and switch to the opposite direction if necessary. More than half the time, this gets the user to their desired information (the timetable) faster than the alternative designs.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-timetable-direction-segmented.jpg&quot; width=&quot;&quot; height=&quot;600&quot; alt=&quot;Station timetable screen in v1.0 with a direction segmented control&quot; title=&quot;Station timetable screen in v1.0 with a direction segmented control&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Station timetable screen in v1.0 with a direction segmented control&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;One alternative design would be to show both rail directions as tappable buttons on the home or search screen.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-alternate-nearby.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Unimplemented alternative design making the user choose both station and direction at the same time&quot; title=&quot;Unimplemented alternative design making the user choose both station and direction at the same time&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Unimplemented alternative design making the user choose both station and direction at the same time&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Another alternative design would be to present an inter-statial screen that only asked: “which direction are you going?”&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-station-detail-alternate.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Alternative design explored during pre-release making the user choose a direction in a separate step after choosing a station before showing the timetable&quot; title=&quot;Alternative design explored during pre-release making the user choose a direction in a separate step after choosing a station before showing the timetable&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Alternative design explored during pre-release making the user choose a direction in a separate step after choosing a station before showing the timetable&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I considered both those alternatives to be clearer in the short term, but slower in the long term once the user understood the flow.&lt;/p&gt;

&lt;p&gt;My guiding principle of the app is speed. Therefore, I chose the v1.0 design solution with the intention to return to the decision later.&lt;/p&gt;

&lt;h2 id=&quot;identifying-user-flows&quot;&gt;Identifying user flows&lt;/h2&gt;

&lt;p&gt;There’s clearly two user main user flows that branch off from when the user opens the app:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A. The user is in a familiar place and chooses a station bookmark&lt;/li&gt;
  &lt;li&gt;B. The user is in an unfamiliar place and chooses a nearby station&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-use-cases.png&quot; width=&quot;&quot; height=&quot;600&quot; alt=&quot;(A) and (B) use cases&quot; title=&quot;(A) and (B) use cases&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;(A) and (B) use cases&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Additionally, there’s a third flow for when the user is perhaps first setting up their bookmarks or doing some other station research.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;C. The user is searching for a station by text&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use cases (B) and (C) are similar in that &lt;em&gt;the user needs to choose a direction&lt;/em&gt;, so I’ll combine them.&lt;/p&gt;

&lt;p&gt;The (A) case is already solved well enough with the existing station timetable screen. If anything, it’d be great to &lt;em&gt;remove&lt;/em&gt; the complexity of having a direction switcher in this case where it’s ambiguously useful.&lt;/p&gt;

&lt;p&gt;My goal was to find a better solution to the (B) use cases than I’d previously pitched (to myself).&lt;/p&gt;

&lt;h2 id=&quot;identifying-design-goals&quot;&gt;Identifying design goals&lt;/h2&gt;

&lt;p&gt;The primary design goal is for the user to find their timetable departure as quickly as possible with as few taps as possible.&lt;/p&gt;

&lt;p&gt;Another key observation I got from my own experience and user feedback was that it’s often quite burdensome trying to figure out which direction is the &lt;em&gt;correct&lt;/em&gt; one in any given trip scenario.&lt;/p&gt;

&lt;p&gt;Of course, this is mostly a solved problem with traditional algorithmic based routing based on departure and destination points, like the UX of Google Maps or Jorudan apps. You type in your destination and the app gives you a route and tells you which platform to wait at.&lt;/p&gt;

&lt;p&gt;But despite of the ease of use of traditional routing apps, I was still finding myself wanting to explore the relative simplicity of a &lt;em&gt;departure-based&lt;/em&gt; UX rather than a &lt;em&gt;destination-based&lt;/em&gt; UX.&lt;/p&gt;

&lt;p&gt;So my secondary design goal was helping the user decide which direction was correct as quickly as possible with the least amount of mental overhead.&lt;/p&gt;

&lt;p&gt;And finally, I realized that I had a lot of often extraneous yet sometimes interesting and useful data about each station that I had nowhere to display. As a tertiary goal, it would be nice to have a place for all that data that respects the principle of &lt;em&gt;progressive disclosure&lt;/em&gt;: staying out of the way while still being accessible when desired.&lt;/p&gt;

&lt;h2 id=&quot;exploring-solutions&quot;&gt;Exploring solutions&lt;/h2&gt;

&lt;p&gt;Starting from the most important of the design goals, I started mapping out the station detail screen.&lt;/p&gt;

&lt;h3 id=&quot;first-design-goal-timetable-departures&quot;&gt;First design goal: timetable departures&lt;/h3&gt;

&lt;p&gt;If we want the user to find their timetable departure as quickly as possible, the most direct solution is to simply show the timetables for both directions as the same time. The upside to this design is that there are no additional taps. The downside is visual complexity.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-side-by-side-timetables.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;An interface sketch with timetable items for directions side-by-side&quot; title=&quot;An interface sketch with timetable items for directions side-by-side&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;An interface sketch with timetable items for directions side-by-side&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Honestly, this was the biggest unknown that I wrestled with throughout the design and implementation process. I doubled down on the bet that the gain in speed would outweigh the visual complexity of the screen. The only way to test it was to progressively build out more and more of the screen, enough so that I could test it in context with a wide variety of stations in a wide variety of situations.&lt;/p&gt;

&lt;p&gt;But showing two directions means doubling much of the data on the screen. And making the visual complexity even worse.&lt;/p&gt;

&lt;p&gt;What fell out of this design decision was that there were situations where the user &lt;em&gt;had&lt;/em&gt; already specified which direction they were interested in. In this case, the screen could hide a full column.&lt;/p&gt;

&lt;p&gt;What made this screen different from the station timetable screen? Well, I would have truncate the full timetable since there were other sections. The fact that I could link out the station timetable screen meant I could be smart about narrowing down the timetable items based on projected use cases.&lt;/p&gt;

&lt;p&gt;Most often, the user is starting their journey &lt;em&gt;now&lt;/em&gt; and doesn’t need earlier departures. I can also bet the user is not planning a journey too far into the future. But I need to account for stations that have significantly frequent departures with different train types and destination stations. Without knowing how much could be fit on the screen, I guessed &lt;em&gt;4 timetable items&lt;/em&gt; would fit the requirements (and later expanded it to 6).&lt;/p&gt;

&lt;p&gt;For better or worse (UX), showing a station’s departures for both directions &lt;strong&gt;solves the first design goal&lt;/strong&gt;.&lt;/p&gt;

&lt;h3 id=&quot;second-design-goal-determining-direction&quot;&gt;Second design goal: determining direction&lt;/h3&gt;

&lt;p&gt;The second design goal – giving the user enough information to determine their direction – also could be solved several ways. I turned to skeuomorphism and the humble rail diagram commonly found in train stations.&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;500&quot; alt=&quot;A rail diagram on the wall at Bashamichi station&quot; title=&quot;A rail diagram on the wall at Bashamichi station&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A rail diagram on the wall at Bashamichi station&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The rail diagram format has several upsides:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Almost all train riders have seen its shape before.&lt;/li&gt;
  &lt;li&gt;I already have the data to construct it dynamically.&lt;/li&gt;
  &lt;li&gt;It maps somewhat logically to directions.&lt;/li&gt;
  &lt;li&gt;It shows both terminal stations and neighboring stations (one of which is usually enough to orient a rider)&lt;/li&gt;
  &lt;li&gt;It implicitly includes railway information so users don’t get confused about e.g. &lt;em&gt;which&lt;/em&gt; Shibuya station they’re looking at.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Posting a rail diagram at the top of the screen acts as a guide. The user can look for their destination station on the left or right side of the rail diagram, and then choose a departure from the timetable on that side. &lt;strong&gt;This solved the second design goal.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The distinct look of the rail diagram also acted as a nice way to distinguish the station detail screen from the station timetable screen or other screens.&lt;/p&gt;

&lt;h3 id=&quot;third-design-goal-tactfully-show-infrequently-used-data&quot;&gt;Third design goal: tactfully show infrequently used data&lt;/h3&gt;

&lt;p&gt;Finally, there was the third design goal of including some lesser used information about the station.&lt;/p&gt;

&lt;p&gt;I started by listing out all the potential pieces of data that might fit.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-data-ideas.jpg&quot; width=&quot;&quot; height=&quot;600&quot; alt=&quot;Station data ideas&quot; title=&quot;Station data ideas&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Station data ideas&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To keep scope reasonable, I didn’t want to force myself to implement all these sections for the first release. But I did want to keep a record of ideas for later while also getting an intuitive feel for how flexible the design would be for adding new types of data.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-layout-ideas.jpg&quot; width=&quot;&quot; height=&quot;700&quot; alt=&quot;Layout ideas for the station detail screen&quot; title=&quot;Layout ideas for the station detail screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Layout ideas for the station detail screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;With this very rough sketch, I got to work on my design-via-SwiftUI procedure.&lt;/p&gt;

&lt;h2 id=&quot;arranging-the-composite-data&quot;&gt;Arranging the composite data&lt;/h2&gt;

&lt;p&gt;The required data for this screen is more complex than all the other screens so far.&lt;/p&gt;

&lt;p&gt;Starting from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Station.ID&lt;/code&gt;, I’d need the full &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Station&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Railway&lt;/code&gt; data.&lt;/p&gt;

&lt;p&gt;For timetables, I’d need 1 or 2 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Direction&lt;/code&gt; for each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Schedule&lt;/code&gt; because I’d committed during the planning phase to support user-selectable &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Schedule&lt;/code&gt; alongside using the automatically calculated schedule for the current day.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Aside: in retrospect, committing to user-selectable &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Schedule&lt;/code&gt; was a significant development burden that was completely unnecessary and unrelated to any design goals. Although I fully designed and built the feature, because of its downstream unintended consequences on the UX of other screens, I ended up removing the option to use it before release.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-schedule-button.jpg&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;A schedule selector button (bottom right) fully implemented but removed before release&quot; title=&quot;A schedule selector button (bottom right) fully implemented but removed before release&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A schedule selector button (bottom right) fully implemented but removed before release&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;For connected stations (i.e. transfer stations, connected railways), I had the data but had not yet imported it into the app’s domain model. I ended up adding this section in the next point release (v1.3).&lt;/p&gt;

&lt;p&gt;Disregarding the additional &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Schedule&lt;/code&gt; infrastructure, it wasn’t significantly slower to create the composite model for this screen than it would have been to assemble a mock data structure. Therefore I designed with the final data structure fetched from the database. This is always preferable to mock data because the marginal cost of choosing other stations as test data to populate your design or prototype is so low. For example, it was much quicker to find station examples from the database with long text that broke my initial layouts.&lt;/p&gt;

&lt;h2 id=&quot;list-sections&quot;&gt;List sections&lt;/h2&gt;

&lt;h3 id=&quot;designing-and-implementing-the-railway-diagram&quot;&gt;Designing and implementing the railway diagram&lt;/h3&gt;

&lt;p&gt;The railway diagram was important enough to the design and implement first.&lt;/p&gt;

&lt;p&gt;I started with a paper sketch that helped me understand the nuanced cases I’d need to cover.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-railway-diagram-cases.jpg&quot; width=&quot;&quot; height=&quot;600&quot; alt=&quot;Sketch of the railway diagram and 5-ish distinct cases&quot; title=&quot;Sketch of the railway diagram and 5-ish distinct cases&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Sketch of the railway diagram and 5-ish distinct cases&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This was a fun design and layout challenge. I wanted to show the terminal stations of the railway and both neighboring stations to the target station. I also wanted to change the line style to be solid when the stations on the diagram were directly connected and dotted when there were other stations in between them.&lt;/p&gt;

&lt;p&gt;Of course, the Yamanote circle line broke a lot of my assumptions and required some special casing and a custom algorithm. But overall I felt like the design ended up where I wanted it to.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-final-railway-diagram.png&quot; width=&quot;&quot; height=&quot;300&quot; alt=&quot;The finished railway diagram&quot; title=&quot;The finished railway diagram&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The finished railway diagram&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;designing-the-timetable-section&quot;&gt;Designing the timetable section&lt;/h3&gt;

&lt;p&gt;I reused the timetable item design language from other parts of the app. Including the 1-character train type indicator. Some lines are all local and all have the same destination, but many do not and require this data in order to be useful at a glance.&lt;/p&gt;

&lt;p&gt;In the first version, I always calculated the timetable lower cutoff based on the current (live updating) time. However, after implementing connecting stations, I realized that when calculating transfer times, it made sense to pass through times from previous screens and allow the user to toggle between them if necessary.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-toggle-departure-time.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Toggling between cutoff times&quot; title=&quot;Toggling between cutoff times&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Toggling between cutoff times&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I was a little unsure of whether the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;...&lt;/code&gt; button would be clear enough to indicate the full station timetable. So far it seems to be okay.&lt;/p&gt;

&lt;h3 id=&quot;last-train-by-type&quot;&gt;Last train by type&lt;/h3&gt;

&lt;p&gt;A bit of speculative feature: I thought seeing last train times at a glance would be occasionally useful. You can see this data by searching for “last” in the Google Maps schedule selector, but it only shows the actual last train regardless of type. As a user, sometimes I want to know when the last limited express train is because for me taking the local is about twice as long.&lt;/p&gt;

&lt;p&gt;Technically, I should also be showing the last trains by both type &lt;em&gt;and&lt;/em&gt; destination. But for most railways, I think type is enough for now.&lt;/p&gt;

&lt;p&gt;Another tough part about this section was what to name it in both English and Japanese.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-last-train.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Last train by type (local, express, etc.)&quot; title=&quot;Last train by type (local, express, etc.)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Last train by type (local, express, etc.)&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;bookmarks&quot;&gt;Bookmarks&lt;/h3&gt;

&lt;p&gt;Bookmarks was one of the most difficult layout choices I had to make for station detail, and I’m still not completely satisfied with it.&lt;/p&gt;

&lt;p&gt;In my view as the designer of Eki Bright, bookmarks are the core feature of the app because they enable lookup speed and so heavily differentiate the app from its full-featured competitors. They also enable use of widgets, another core feature of the app.&lt;/p&gt;

&lt;p&gt;The flow of adding bookmarks needs to be &lt;em&gt;fast&lt;/em&gt;, &lt;em&gt;obvious&lt;/em&gt;, and &lt;em&gt;efficient&lt;/em&gt;, but after bookmarks are added, it will be rarely used. This makes it very difficult to design for.&lt;/p&gt;

&lt;p&gt;I ended up compromising. I added a bookmark icon to the top right navigation bar that scrolls to the bookmark button section lower in the list.&lt;/p&gt;

&lt;video src=&quot;/images/station-detail-bookmark-jump.mp4&quot; controls=&quot;&quot; preload=&quot;auto&quot; height=&quot;400&quot;&gt;&lt;/video&gt;

&lt;p&gt;Why not just have the button in the navigation bar work as the real button? Each station direction can have its own independent bookmark, so it makes more sense to have buttons segregated in the same way.&lt;/p&gt;

&lt;p&gt;Still, it’s confusing, and definitely requires a more long term solution.&lt;/p&gt;

&lt;h3 id=&quot;train-types&quot;&gt;Train types&lt;/h3&gt;

&lt;p&gt;Getting less useful now, but I still think it’s good to see an overview of which train types exists for more complicated railways.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-train-types.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;A list of train types that run from this station and their assigned category&quot; title=&quot;A list of train types that run from this station and their assigned category&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A list of train types that run from this station and their assigned category&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In a future version, I’d like to add some calculations for each train type to show an average of how often each arrives and what part of the day each is active for.&lt;/p&gt;

&lt;h3 id=&quot;destination-stations&quot;&gt;Destination stations&lt;/h3&gt;

&lt;p&gt;Another rarely useful bit of information, depending on the railway. I added this mostly because it was easy to calculate.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-destinations.png&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;A list of destination stations and how many trains end there on the scheduled day&quot; title=&quot;A list of destination stations and how many trains end there on the scheduled day&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A list of destination stations and how many trains end there on the scheduled day&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;rail-direction-toggle&quot;&gt;Rail direction toggle&lt;/h3&gt;

&lt;p&gt;For stations that have more than one direction, the toolbar is shown with individually toggleable direction buttons.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/station-detail-toggle-direction.gif&quot; width=&quot;&quot; height=&quot;400&quot; alt=&quot;Tap either button to hide or show it in the list&quot; title=&quot;Tap either button to hide or show it in the list&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Tap either button to hide or show it in the list&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;self-critique&quot;&gt;Self critique&lt;/h2&gt;

&lt;p&gt;As discussed above at various points, I think this screen has plenty of room left for improvement. However, I’m pleased enough with how it accomplishes the three design goals I set out to accomplish.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Railway diagram: there are a few stations where the text gets cut off in strange ways.&lt;/li&gt;
  &lt;li&gt;Bookmarks: I’d like to experiment with popping up a separate modal for adding a bookmark instead of scrolling the list.&lt;/li&gt;
  &lt;li&gt;Direction toggle buttons: I’d like to try moving these buttons above the railway diagram. Although important, in real usage I rarely find myself changing them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’m of course interested in real user feedback. If you’re a Tokyo-area resident, &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 the app&lt;/a&gt;, give it a go, and send me your thoughts.&lt;/p&gt;
</description>
        <pubDate>Mon, 14 Oct 2024 17:20:00 -0500</pubDate>
        <link>https://twocentstudios.com/2024/10/14/eki-bright-station-detail/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2024/10/14/eki-bright-station-detail/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>ekibright</category>
        
        
      </item>
    
      <item>
        <title>Eki Bright - Developing the App for iOS</title>
        <description>&lt;p&gt;Continuing my series of posts about my latest iOS app, Eki Bright 駅ブライト, I’ll discuss the more interesting parts of app at version 1.0.&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;Please check out the other posts in the series:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;a href=&quot;/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;Eki Bright - Tokyo Area Train Timetables&lt;/a&gt;&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;This post&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;stats&quot;&gt;Stats&lt;/h2&gt;

&lt;p&gt;Before starting digging into the details, I’ll go over some stats about the app for context.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The app has 6 screens (5 with behavior).&lt;/li&gt;
  &lt;li&gt;The app has around 5700 lines of Swift code.&lt;/li&gt;
  &lt;li&gt;The app has a minimum deployment target of iOS 17.0.&lt;/li&gt;
  &lt;li&gt;The app supports only the iPhone form factor. There’s no technical reason for this. It was mostly to reduce complexity, promotional material requirements, and testing for the first release.&lt;/li&gt;
  &lt;li&gt;The app uses about 5 packages via Swift Package Manager: ComposableArchitecture, Collections, AsyncAlgorithms, DependenciesAdditions, GRDB, HolidayJp. ComposableArchitecture relies on several other pointfreeco packages, which I also use directly.&lt;/li&gt;
  &lt;li&gt;The app uses a basic xcodeproj file.&lt;/li&gt;
  &lt;li&gt;The app was archived with Xcode 17.4, which includes support for Swift 5.9, iOS 17, and (colloquially) SwiftUI 4.&lt;/li&gt;
  &lt;li&gt;The app has a SwiftUI &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;App&lt;/code&gt; entry point.&lt;/li&gt;
  &lt;li&gt;The database of railway, station, and timetable data is generated by a script at develop-time and included in the app bundle at compile-time. The app makes no network requests.&lt;/li&gt;
  &lt;li&gt;The app uses the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;licenseplist&lt;/code&gt; library installed via Homebrew for generating a license file for packages.&lt;/li&gt;
  &lt;li&gt;The app embeds no analytics framework.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;work-schedule&quot;&gt;Work schedule&lt;/h2&gt;

&lt;h3 id=&quot;prototype&quot;&gt;Prototype&lt;/h3&gt;

&lt;p&gt;I started researching and prototyping the app in mid-November. This version had support for one station going inbound on weekdays. It parsed directly from a folder of the raw json files from my data source. There was no importing and no custom database yet. It had one station timetable screen and one widget layout.&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;I was excited about the animation support in the first widget&quot; title=&quot;I was excited about the animation support in the first widget&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;I was excited about the animation support in the first widget&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-proto-station-timetable.jpg&quot; width=&quot;&quot; height=&quot;550&quot; alt=&quot;The only screen in the app; a station timetable for one station, inbound, on weekdays&quot; title=&quot;The only screen in the app; a station timetable for one station, inbound, on weekdays&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The only screen in the app; a station timetable for one station, inbound, on weekdays&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;After a week of light usage, I’d decided to continue on to start on a production version intended for wide release.&lt;/p&gt;

&lt;h3 id=&quot;sprint-1&quot;&gt;Sprint 1&lt;/h3&gt;

&lt;p&gt;I wrote an import script to turn the input json files into a SQLite database (more on this later). I outlined a simple screen structure, still with the idea that the main app would mostly be a pre-configuration step for the widgets that users would be most interested in.&lt;/p&gt;

&lt;p&gt;By early-December 2023 I had a home screen with bookmarks; a station timetable screen that could show any combination of station, direction, and schedule; a station search screen that worked with romaji; and a station detail screen where you could add or remove a bookmark.&lt;/p&gt;

&lt;video src=&quot;/images/eki-bright-sprint-1-demo.mp4&quot; controls=&quot;&quot; preload=&quot;none&quot; poster=&quot;/images/eki-bright-sprint-1-demo.jpg&quot; width=&quot;300&quot;&gt;&lt;/video&gt;

&lt;p&gt;I took a break from the project for several months, whilst still continuing to use and analyze the fruits of this first sprint.&lt;/p&gt;

&lt;h3 id=&quot;sprint-2&quot;&gt;Sprint 2&lt;/h3&gt;

&lt;p&gt;I started the second sprint in June 2024.&lt;/p&gt;

&lt;p&gt;Although I always had a freshly pruned TODO list, my day-to-day usage of the app while out exploring Tokyo guided a lot of my feelings of what was missing from the first release.&lt;/p&gt;

&lt;p&gt;I made the big decision to only target Japanese for the first release. This required ensuring search worked equally well with hiragana, kanji, and romaji.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-sprint-2-search.gif&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Search supporting romaji, hiragana, and kanji&quot; title=&quot;Search supporting romaji, hiragana, and kanji&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Search supporting romaji, hiragana, and kanji&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;When out riding the trains, I realized a few things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I was using the main app a lot more than I expected; not just for my home stations.&lt;/li&gt;
  &lt;li&gt;Text search was procedurally too slow to use to look up nearby stations in the moment.&lt;/li&gt;
  &lt;li&gt;Live updating nearby stations would unlock several use cases I was really excited about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I took a detour to implement live nearby stations, first in a dedicated screen, then as a smaller section on the on the home screen.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-sprint-2-nearby.jpg&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;A standalone version of the nearby screen&quot; title=&quot;A standalone version of the nearby screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A standalone version of the nearby screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Next, I had to tackle the production implementation of widgets, including a mechanism to select which station/direction pair (in the form of a bookmark) appeared in the widget.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-sprint-2-widget-select-station.jpg&quot; width=&quot;&quot; height=&quot;250&quot; alt=&quot;An early version of bookmark selection from the edit widget screen&quot; title=&quot;An early version of bookmark selection from the edit widget screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;An early version of bookmark selection from the edit widget screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I was steadily making UX and UI tweaks across the app’s few screens, especially when considering the default Japanese localization. Especially on the station timetable screen. I implemented the swipe &amp;amp; segmented control for switching rail directions, support for showing the entire day’s schedule, showing the current time in-line with the timetable entries, and tweaking font sizes and colors.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-sprint-2-timetable-now.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Experimenting with how to show the current time in line with timetable items&quot; title=&quot;Experimenting with how to show the current time in line with timetable items&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Experimenting with how to show the current time in line with timetable items&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;I took a (probably unnecessary for version 1) sidebar to implement a train timetables screen. It shows which stations a train will visit on its journey. This was another common request from my beta testers, and a feature that vastly increased my usage opportunities for the app while riding. Users could now quickly view the destination stations of an express train on an unfamiliar railway and see whether they should get on or wait for the next one.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-sprint-2-train-timetable.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;A early version of the train timetable screen&quot; title=&quot;A early version of the train timetable screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A early version of the train timetable screen&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The core features of the app felt pretty good. I moved onto onboarding and the about screen. It was nice to be able to copy the about screen nearly verbatim from Count Biki. Code reuse is usually never that straightforward in practice.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-sprint-2-onboarding.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;Iterating on onboarding calls-to-action in English first&quot; title=&quot;Iterating on onboarding calls-to-action in English first&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Iterating on onboarding calls-to-action in English first&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-sprint-2-tip-kit.jpg&quot; width=&quot;&quot; height=&quot;250&quot; alt=&quot;I even found a use for the Tip Kit framework new in iOS 17&quot; title=&quot;I even found a use for the Tip Kit framework new in iOS 17&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;I even found a use for the Tip Kit framework new in iOS 17&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The last 1% (that takes 99% of the time) was copywriting for the App Store listing, designing the icon, and submitting.&lt;/p&gt;

&lt;h2 id=&quot;architecture&quot;&gt;Architecture&lt;/h2&gt;

&lt;p&gt;The app is built all-in with &lt;a href=&quot;https://github.com/pointfreeco/swift-composable-architecture&quot;&gt;The Composable Architecture (TCA) 1.11&lt;/a&gt;. This includes passing and scoping &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Store&lt;/code&gt;s through the view layer, creating &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Reducer&lt;/code&gt;s with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;State&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Action&lt;/code&gt;s for most screens, and creating dependencies in the style of the &lt;a href=&quot;https://github.com/pointfreeco/swift-dependencies&quot;&gt;swift-dependencies&lt;/a&gt; library.&lt;/p&gt;

&lt;p&gt;This follows on from &lt;a href=&quot;/2023/10/31/count-biki-developing-the-app-for-ios/&quot;&gt;my experience&lt;/a&gt; building my previous app Count Biki. TCA underwent significant changes between the time I finished Count Biki and started working on Eki Bright.&lt;/p&gt;

&lt;p&gt;The most significant (and welcome) change was the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Shared&lt;/code&gt; state-sharing system. It made my iterations on the bookmark system relatively trivial. Just a lot less boilerplate than I’d previously needed. Also, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Shared&lt;/code&gt; worked without a hitch in the widget extension.&lt;/p&gt;

&lt;p&gt;Overall, I’m getting a lot more comfortable with TCA and feel pretty confident using it as my default architecture on indie apps going forward.&lt;/p&gt;

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

&lt;h3 id=&quot;importing-and-storage&quot;&gt;Importing and storage&lt;/h3&gt;

&lt;p&gt;I used the responsible (read: no premature optimization) strategy of starting my prototyping by live parsing the json files directly from my data provider. From this experience, I decided to implement a more robust and flexible data storage system before a first release.&lt;/p&gt;

&lt;p&gt;As mentioned in my &lt;a href=&quot;/2024/07/27/eki-bright-tokyo-area-train-timetables/&quot;&gt;intro post&lt;/a&gt;, the overarching theme of this app is speed. Searching a directory structure for the appropriate timetable file, loading multiple megabyte json files into memory, parsing them, and aggregating the results on each request did not align with my speed goals. Especially considering the various views of that data I wanted to support.&lt;/p&gt;

&lt;p&gt;Of the many many data storage and access systems available, I chose &lt;a href=&quot;https://github.com/groue/GRDB.swift&quot;&gt;GRDB&lt;/a&gt;, a long-lived wrapper library for SQLite. Even though my requirements are of relatively minimal scale, I still can’t get get onboard with a nascent solution like Swift Data that has obvious brick-wall functionality limitations and no public roadmap as to when they will be addressed. GRDB, on the other hand, lightly abstracts SQLite, has been in heavy usage by the community for years, has robust documentation, has an extensive set of quick start code snippets, and a well respected and responsive maintainer.&lt;/p&gt;

&lt;p&gt;A reasonable solution in our networked world would have been to host the data on my own server and perform a network request for each timetable, etc. I could have added caching layers to improve response times in the common case.&lt;/p&gt;

&lt;p&gt;However, using the network goes against my speed goal. Although network coverage is admirably solid underground in Tokyo, there’s enough chance of drop outs that I wouldn’t want my users to lose trust in the app in those few cases there’s no signal for a duration of their request. All things equal, with implementation simplicity comes robustness. And a local database removes many breakable layers of abstraction from the request/response chain.&lt;/p&gt;

&lt;p&gt;The tradeoff is app storage space. The database started at around 300 MB, then 400 MB, then ballooned to over 1 GB (related to app extension issues), before I could optimize it pre-release back down to ~225 MB. Still quite large for such a simple app, but again, it’s both a trade-off I committed to up front and differentiation point.&lt;/p&gt;

&lt;p&gt;One of the best decisions I made was to invest in creating a simple and well-documented Swift script dedicated to reading JSON files and spitting out a single SQLite database. I ended up running this script dozens of times. A full import now takes about 10 minutes of wall-clock time, but in the early stages of development took less than a minute.&lt;/p&gt;

&lt;p&gt;The import script allowed me to make iterative decisions to my schema, normalization strategy, naming, data supplements unique to my app, etc. during development. I’d simply switch targets, run the script, and replace the database file in the app directory.&lt;/p&gt;

&lt;p&gt;Regarding the schema, I specifically decoupled the “import” model types from the “app” model types. This means that I can make the normalization/de-normalization decisions that affect query times &lt;em&gt;before&lt;/em&gt; compile time.&lt;/p&gt;

&lt;p&gt;For example, for search, I added a “queryTerms” field that aggregates all language variations of a station name e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aomi aoumi あおうみ 青海 アオウミ&lt;/code&gt;. I can create this &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;queryTerms&lt;/code&gt; field at import time so no CPU time is dedicated to it at run time.&lt;/p&gt;

&lt;p&gt;The import step also enables me to do data consistency and invariant checks. These are especially important as my provider’s data is expected to evolve over time, and relations between JSON data can get silently corrupted.&lt;/p&gt;

&lt;h3 id=&quot;modeling-dates&quot;&gt;Modeling dates&lt;/h3&gt;

&lt;p&gt;A couple invariants about dates:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The data provider uses 24-hour time, within the range 00:00-23:59, specified as strings.&lt;/li&gt;
  &lt;li&gt;There are no 24-hour trains in the Tokyo train network at the moment.&lt;/li&gt;
  &lt;li&gt;Timetable schedules are specified conceptually as either &lt;em&gt;weekday&lt;/em&gt;, &lt;em&gt;saturday&lt;/em&gt;, &lt;em&gt;sunday/holiday&lt;/em&gt;, or &lt;em&gt;weekend&lt;/em&gt; depending on the railway.&lt;/li&gt;
  &lt;li&gt;No trains on any railway start before 3am.&lt;/li&gt;
  &lt;li&gt;No trains on any railway end after 3am.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I had two structs for dealing with these invariants.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HourMinute&lt;/code&gt; stores times at the most useful precision. It stores times as &lt;em&gt;24+ hour time&lt;/em&gt;, which considers early morning the next day as part of the current day. It allows easy comparison and adjustment. It ensures there are no out of bounds times.&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;HourMinute&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;Comparable&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;hours&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// 3...26&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;minutes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// 0...59&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;earliest&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;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;uncheckedHours&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;minutes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;latest&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;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;uncheckedHours&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;26&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;minutes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;59&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;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uncheckedHours&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;hours&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;minutes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&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;self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hours&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;hours&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;n&quot;&gt;minutes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;minutes&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;nv&quot;&gt;hours&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;minutes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Int&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;nf&quot;&gt;guard&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;n&quot;&gt;earliest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hours&lt;/span&gt; &lt;span class=&quot;o&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;n&quot;&gt;latest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hours&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;contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hours&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;Self&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;earliest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;minutes&lt;/span&gt; &lt;span class=&quot;o&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;n&quot;&gt;latest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;minutes&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;contains&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;minutes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&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;nf&quot;&gt;assertionFailure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Out of bounds&quot;&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;kc&quot;&gt;nil&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;n&quot;&gt;hours&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;hours&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;n&quot;&gt;minutes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;minutes&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;When the user selects a station/direction pair, there are either 2 or 3 different timetables that we could show depending on the current time.&lt;/p&gt;

&lt;p&gt;For example, on a honest Saturday, a particular railway may not have specific &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;saturday&lt;/code&gt; schedule, but instead may have a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nonWeekday&lt;/code&gt; schedule also valid on Sundays or holidays.&lt;/p&gt;

&lt;p&gt;In order to determine which one to show, I use a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ScheduleSelector&lt;/code&gt; helper to map the current logical schedule (weekday, Saturday, Sunday, or Holiday) to the potential &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Schedule&lt;/code&gt; that has a timetable.&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;enum&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ScheduleSelector&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;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;weekday&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;saturday&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sunday&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;holiday&lt;/span&gt;
    
    &lt;span class=&quot;c1&quot;&gt;/// For a named day, which schedules are valid?&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;possibleSchedules&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;Schedule&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;k&quot;&gt;self&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;weekday&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;n&quot;&gt;weekday&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;saturday&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;n&quot;&gt;saturday&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;nonWeekday&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;sunday&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;n&quot;&gt;holiday&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;nonWeekday&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;holiday&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;n&quot;&gt;holiday&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;nonWeekday&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;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;If the user checks the app after midnight, I need to make sure the app still fetches the schedule from the previous calendar day.&lt;/p&gt;

&lt;p&gt;There’s some annoying date math involved. But since all schedules are locked to one timezone, the implementation is less complicated than it would be otherwise.&lt;/p&gt;

&lt;h3 id=&quot;train-categories&quot;&gt;Train Categories&lt;/h3&gt;

&lt;p&gt;A &lt;em&gt;train type&lt;/em&gt; is a specific name expressing which stations on a railway a train will stop. For example, “local” or “express” in English and “各駅” or “急行” in Japanese.&lt;/p&gt;

&lt;p&gt;The names for train types follow conventions, but they are by no means standardized. For example, the Toyoko line has several train types, with a few only present on weekends:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Local&lt;/li&gt;
  &lt;li&gt;Express&lt;/li&gt;
  &lt;li&gt;Limited Express&lt;/li&gt;
  &lt;li&gt;Commuter Limited Express&lt;/li&gt;
  &lt;li&gt;Semi Express&lt;/li&gt;
  &lt;li&gt;F-Liner&lt;/li&gt;
  &lt;li&gt;S-TRAIN&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A train type with the same name on a different railway may have a different color indicator. This color may not even be consistent across information sources for the same railway.&lt;/p&gt;

&lt;p&gt;The outlier train type names can be long, even in Japanese. When I was designing the more space-constrained widgets, I realized it would be necessary to standardize train types even further for the sake of my app users.&lt;/p&gt;

&lt;p&gt;I manually created my own system of &lt;em&gt;train categories&lt;/em&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;enum&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TrainCategoryKind&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;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;kt&quot;&gt;Sendable&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;n&quot;&gt;local&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rapid&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;semiExpress&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;express&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;limited&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;colorHexString&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;cm&quot;&gt;/* ... */&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;oneCharacterJA&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;k&quot;&gt;switch&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;self&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;local&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;各&quot;&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;rapid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;快&quot;&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;semiExpress&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;準&quot;&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;express&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;急&quot;&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;limited&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;特&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;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-widget-category-character.jpg&quot; width=&quot;&quot; height=&quot;250&quot; alt=&quot;The information-dense Accessory Regular widget size necessitates a single-character identifier for a train type&quot; title=&quot;The information-dense Accessory Regular widget size necessitates a single-character identifier for a train type&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The information-dense Accessory Regular widget size necessitates a single-character identifier for a train type&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-widget-category-color.jpg&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;In larger widget sizes where color is supported, color is a useful indicator&quot; title=&quot;In larger widget sizes where color is supported, color is a useful indicator&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;In larger widget sizes where color is supported, color is a useful indicator&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;My design philosophy for the app is &lt;em&gt;speed&lt;/em&gt;, and I also assume long-term usage from users who are familiar with a railway (at least enough that they’d need a widget for it). Therefore, I think its reasonable for users to learn a shorthand to distinguish between a “Local” and an “SL-Taiju” train type.&lt;/p&gt;

&lt;p&gt;Of course, tapping on a widget takes you directly to the full station timetable in the app where the full Christian-name of the train type is shown, in case there was any confusion.&lt;/p&gt;

&lt;p&gt;In early betas, I had even fewer categories, but thanks to some helpful feedback (thanks, Dave), I realized there was not enough differentiation for some railways in having only 3 categories.&lt;/p&gt;

&lt;h3 id=&quot;fetching&quot;&gt;Fetching&lt;/h3&gt;

&lt;p&gt;The interface to my database is pretty straightforward.&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;@DependencyClient&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AppDatabaseClient&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;fetchStation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&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;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Station&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;ID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Station&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;fetchStationTimetable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&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;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Station&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;ID&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;railDirection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;RailDirection&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;scheduleSelector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;ScheduleSelector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;StationTimetableResponse&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;fetchTrainTimetables&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&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;trainTimetableID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TrainTimetable&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;ID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;TrainTimetableResponse&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;fetchStationBookmarks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&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;stationBookmarks&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;StationBookmark&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;StationDirectionDetail&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;search&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&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;query&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;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;StationSearchResult&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;fetchNearbyStations&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;@Sendable&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;coordinate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;CLLocationCoordinate2D&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ync&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;NearbyStationResult&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;Perhaps surprisingly based on my commitment to speed as written thus far, I did not spend much time optimizing my database queries.&lt;/p&gt;

&lt;p&gt;My goal was to get the query working correctly with the most obvious code. Based on testing, this was usually perceivably instantaneous from the user perspective, and therefore needed no more optimization. As of v1.0, essentially all database joins are done manually in Swift.&lt;/p&gt;

&lt;p&gt;When I implement more complex screens with several disparate tables worth of data, I’ll probably need to revisit this! But until then, I feel like I’ve met my speed goals.&lt;/p&gt;

&lt;h3 id=&quot;train-timetables-recursion&quot;&gt;Train timetables recursion&lt;/h3&gt;

&lt;p&gt;A unique part of the Tokyo rail network is that railways across different companies share trains.&lt;/p&gt;

&lt;p&gt;For example, a train that starts at the end of the Minatomirai line transforms into a train on the Toyoko line train at Yokohama station, which then becomes a train on the Fukutoshin line at Shibuya station (which can actually transfer again).&lt;/p&gt;

&lt;p&gt;Note that each of these railways is owned by a different company (Yokohama Minatomirai Railway Company, Tokyu Corportation, and Tokyo Metro Co. Ltd., respectively). I don’t quite understand the politics of how this arrangement works for the railway companies, but for riders it’s incredibly convenient.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-train-timetable-multiple-railways.gif&quot; width=&quot;&quot; height=&quot;550&quot; alt=&quot;A train starting in Motomachi, Yokohama and ending in Hanno, Saitama transfers seamlessly through 5 different railways&quot; title=&quot;A train starting in Motomachi, Yokohama and ending in Hanno, Saitama transfers seamlessly through 5 different railways&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A train starting in Motomachi, Yokohama and ending in Hanno, Saitama transfers seamlessly through 5 different railways&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In the dataset, a train timetable item has an optional forward and reverse link to another train timetable item. In this way, I can recursively fetch train timetables before and after the one relevant to the user’s selected station.&lt;/p&gt;

&lt;p&gt;While testing my first implementation of this, I navigated to a Yamanote train timetable and realized that, since it’s a loop line, the same trains go from early morning to late evening.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-train-timetable-yamanote.gif&quot; width=&quot;&quot; height=&quot;550&quot; alt=&quot;A train on the Yamanote line circles all day&quot; title=&quot;A train on the Yamanote line circles all day&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;A train on the Yamanote line circles all day&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;nearby-stations&quot;&gt;Nearby stations&lt;/h2&gt;

&lt;p&gt;I modeled the infrastructure for the nearby stations feature as a composition of 3 clients:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DeviceLocationClient&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AppDatabaseClient&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NearbyStationsClient&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NearbyStationsClient&lt;/code&gt; transforms location events from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DeviceLocationClient&lt;/code&gt; into a list of stations fetched by coordinates from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AppDatabaseClient&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;devicelocationclient&quot;&gt;DeviceLocationClient&lt;/h3&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;DeviceLocationClient&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;locationEvents&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;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;LocationEvent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;enum&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;LocationEvent&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;n&quot;&gt;paused&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Location&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;n&quot;&gt;unavailable&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;Under-the-hood, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DeviceLocationClient&lt;/code&gt; uses the iOS 17 &lt;a href=&quot;https://developer.apple.com/documentation/corelocation/cllocationupdate/4211321-liveupdates&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate.liveUpdates&lt;/code&gt;&lt;/a&gt; API from the CoreLocation framework. I decided to investigate &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; instead of the long-standing (but clunky) &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationManager&lt;/code&gt; API.&lt;/p&gt;

&lt;p&gt;Unfortunately, the first implementation of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt; in iOS 17 is not fully baked. iOS 18 fixed several behavioral oddities, missing APIs, and integration points.&lt;/p&gt;

&lt;p&gt;After spending significant time integrating &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLLocationUpdate&lt;/code&gt;, I ultimately decided to temporarily accept its deficiencies with the intention to update to the iOS 18 version after a few months.&lt;/p&gt;

&lt;h3 id=&quot;nearbystationsclient&quot;&gt;NearbyStationsClient&lt;/h3&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;NearbyStationsClient&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;nearbyStations&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;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;AsyncStream&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;NearbyStationsEvent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;enum&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;NearbyStationsEvent&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;n&quot;&gt;paused&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;Location&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;NearbyStationResult&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;n&quot;&gt;unavailable&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;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NearbyStationsClient&lt;/code&gt; parses the stream of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LocationEvent&lt;/code&gt;s and converts them to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NearbyStationsEvent&lt;/code&gt;s by performing a search for each set of coordinates on the station database.&lt;/p&gt;

&lt;p&gt;In the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AppDatabaseClient&lt;/code&gt;, I calculate the minimum and maximum latitude and longitude around the input coordinate (4 total floats), then use these in the SQL query filter.&lt;/p&gt;

&lt;p&gt;For each raw station result from the database, I calculate the actual distance for each, sort the results, and do one last filter in case the station isn’t a departure station.&lt;/p&gt;

&lt;h3 id=&quot;nearbystationsfeature&quot;&gt;NearbyStationsFeature&lt;/h3&gt;

&lt;p&gt;The feature layer parses the stream of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NearbyStationsEvent&lt;/code&gt;s and uses the raw &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Location&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[NearbyStationResult]&lt;/code&gt; to calculate a walk estimate in minutes for each result since a distance in meters is not always easy to intuit at a glance.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NearbyStationsFeature&lt;/code&gt; powers both the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;List&lt;/code&gt; section on the home screen and the dedicated nearby stations screen.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-nearby-stations-screen.jpg&quot; width=&quot;&quot; height=&quot;450&quot; alt=&quot;The nearby stations dedicated screen&quot; title=&quot;The nearby stations dedicated screen&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The nearby stations dedicated screen&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;widgets&quot;&gt;Widgets&lt;/h2&gt;

&lt;p&gt;This project was my first time implementing widgets. The API and its gradual integration with AppIntents over the years made it tougher than I expected as I ventured beyond its trivial configurations.&lt;/p&gt;

&lt;p&gt;There’s only one type of widget in Eki Bright so far: station timetable widgets. I support a few families: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemMedium&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemSmall&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;accessoryRegular&lt;/code&gt;. I don’t support the Apple Watch in v1.0.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-widget-category-color.jpg&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;Two `systemSmall`-family widgets side-by-side under the `systemMedium`-family stock Weather app widget&quot; title=&quot;Two `systemSmall`-family widgets side-by-side under the `systemMedium`-family stock Weather app widget&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Two `systemSmall`-family widgets side-by-side under the `systemMedium`-family stock Weather app widget&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Aside: I keep relearning the lesson that it’s useful to keep up to date with all the new frameworks at WWDC each year even if you don’t plan to use them right away. Because often the Apple engineers explain and document updates with the assumption that you already understand that SiriKit was deprecated in favor of Intents which was deprecated in favor of App Intents.&lt;/p&gt;

&lt;h3 id=&quot;timeline-provider-implementation&quot;&gt;Timeline provider implementation&lt;/h3&gt;

&lt;p&gt;Implementing a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TimelineProvider&lt;/code&gt; for a widget requires 3 functions:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;placeholder&lt;/code&gt; - for showing the form of the widget instantaneously.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;snapshot&lt;/code&gt; - for showing an accurate form of the widget quickly with real data.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timeline&lt;/code&gt; - for showing many accurate states of the widget over time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The implementation of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;snapshot&lt;/code&gt; is similar to getting the data for the corresponding station timetable screen in the app.&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;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;now&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Get the entire timetable for the station, direction, and schedule&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timetable&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fetchStationTimetable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;selectedStation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;station&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railDirection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;selectedStation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;direction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;scheduleSelector&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;from&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Filter the timetable to ~9 entries&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;filteredTimetable&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;filtered&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timetable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timetable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;family&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;family&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Return success&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;nf&quot;&gt;init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timetable&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;success&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;filteredTimetable&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 implementation of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timeline&lt;/code&gt; is more involved.&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;c1&quot;&gt;// Same as `snapshot` above&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;now&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&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;fullTimetable&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fetchStationTimetable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;selectedStation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;station&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railDirection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;selectedStation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;direction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;scheduleSelector&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;from&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;now&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;nowTimetable&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;filtered&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timetable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fullTimetable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;family&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;family&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;nowEntry&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TimetableTimelineEntry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timetable&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;success&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;nowTimetable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Determine when the timeline should reload&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;reloadDate&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Date&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;nextDay3am&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;calendar&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;isoTokyoCalendar&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Create an array of future dates that correspond with the minute _after_ a train leaves&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;allFutureDates&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fullTimetable&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timetableItems&lt;/span&gt;
    &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;compactMap&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;departureTime&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;advancedOneMinute&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;n&quot;&gt;map&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;toDate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;now&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;n&quot;&gt;filter&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Create a filtered timetable based on each of those dates&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;allFutureTimetables&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;allFutureDates&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;map&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;filtered&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;for&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timetable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fullTimetable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;family&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;family&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;c1&quot;&gt;// Create a TimelineEntry for each and return&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;allFutureEntries&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;zip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;allFutureDates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;allFutureTimetables&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;map&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TimetableTimelineEntry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timetable&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;success&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$1&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;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;allEntries&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;nowEntry&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;allFutureEntries&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Timeline&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;entries&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;allEntries&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;policy&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;after&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reloadDate&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;Additionally, I check a few error-ish conditions before calculating the snapshot or timeline:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The database has been loaded correctly.&lt;/li&gt;
  &lt;li&gt;The user has at least one bookmark in the main app (otherwise I can guide them to do that first).&lt;/li&gt;
  &lt;li&gt;The user has selected a station from the edit widget menu (otherwise I can guide them to do that first).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;sharing-data-with-the-main-app&quot;&gt;Sharing data with the main app&lt;/h3&gt;

&lt;p&gt;Because a widget is an app extension, it requires extra steps to share data.&lt;/p&gt;

&lt;h4 id=&quot;user-settings-in-the-file-system&quot;&gt;User settings in the file system&lt;/h4&gt;

&lt;p&gt;My user settings was stored as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Codable&lt;/code&gt;-generated json file in the file system within the app’s sandbox. I wanted to share the list of station bookmarks from that model with the widget extension.&lt;/p&gt;

&lt;p&gt;I followed the procedure to enable App Groups for the App ID in the developer portal, registered the group ID in the app target, registered the group ID to the widget target, then changed the location of my user settings file.&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;kt&quot;&gt;FileManager&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;default&lt;/span&gt;
	&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;containerURL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;forSecurityApplicationGroupIdentifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;group.com.twocentstudios.train-timetable&quot;&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;appendingPathComponent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;user-settings&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;conformingTo&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;json&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;With that change, I could access the user settings struct from the AppIntent the same way I did in the main app.&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;public&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;StationBookmarkEntityQuery&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;EntityQuery&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;allBookmarks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;IdentifiedArrayOf&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;StationBookmark&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;

    &lt;span class=&quot;kd&quot;&gt;public&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;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;@SharedReader(.userSettings)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;userSettings&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;allBookmarks&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userSettings&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stationBookmarks&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    
    &lt;span class=&quot;c1&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;h4 id=&quot;database-as-a-file-from-the-bundle&quot;&gt;Database as a file from the bundle&lt;/h4&gt;

&lt;p&gt;Sharing the database was more difficult to solve. But that goes back to the unique database use case I have.&lt;/p&gt;

&lt;p&gt;I’m using an SQLite database generated at development-time by my aforementioned Swift script. The database is read-only, bundled within the app, and is versioned alongside the app itself. This is perhaps a less-than-usual use case for an SQLite database according to the documentation around the internet.&lt;/p&gt;

&lt;p&gt;I decided to see how far I could get with opening the database directly from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bundle&lt;/code&gt;. It’s worked fine this way so far for my use case.&lt;/p&gt;

&lt;p&gt;For simplicity, the widget reuses the same database access code as the main app. It therefore requires access to the database file.&lt;/p&gt;

&lt;p&gt;During most of development, I gave the widget extension a copy of the database for its own bundle. I wasn’t completely aware of the implications of this at first, only that it seemed to work. Unfortunately, it does make a full copy of the database file, and results in 2 full identical copies of the database shipped with the app. The size of my database makes this quite large.&lt;/p&gt;

&lt;p&gt;It took some research, but I found an &lt;a href=&quot;https://stackoverflow.com/a/27849695&quot;&gt;admittedly brittle way&lt;/a&gt; to access the app’s bundle from the main bundle by hacking around on the bundle URL. This means I only have to include the database in the main target.&lt;/p&gt;

&lt;p&gt;I’m not confident in this solution, but I still prefer it over superfluously taking ~300 MB of every user’s precious device storage.&lt;/p&gt;

&lt;h3 id=&quot;previews&quot;&gt;Previews&lt;/h3&gt;

&lt;p&gt;Previews worked great while I was designing and implementing the prototype.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-v1-static-widget-previews.jpg&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;SwiftUI previews showing widget context variants for a static timeline entry&quot; title=&quot;SwiftUI previews showing widget context variants for a static timeline entry&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;SwiftUI previews showing widget context variants for a static timeline entry&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Unfortunately, as soon as I needed to upgrade to a AppIntent-powered timeline provider, the available &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#Preview&lt;/code&gt; macro no longer compiles.&lt;/p&gt;

&lt;p&gt;The below &lt;a href=&quot;https://developer.apple.com/documentation/widgetkit/preview(_:as:using:widget:timelineprovider:)-4ljg1&quot;&gt;two overloaded macros&lt;/a&gt; do not compile under any circumstance for me.&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;c1&quot;&gt;// Error: Macro &apos;Preview(_:as:using:widget:timelineProvider:)&apos; requires that &apos;TestIntentTimelineProvider&apos; conform to &apos;AppIntentTimelineProvider&apos;&lt;/span&gt;
&lt;span class=&quot;cp&quot;&gt;#Preview(&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;as&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;systemMedium&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;using&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TestIntent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;widget&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;TestWidget&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;nv&quot;&gt;timelineProvider&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;TestIntentTimelineProvider&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;c1&quot;&gt;// Error: Macro &apos;Preview(_:as:using:widget:timelineProvider:)&apos; requires that &apos;TestAppIntentTimelineProvider&apos; conform to &apos;IntentTimelineProvider&apos;&lt;/span&gt;
&lt;span class=&quot;cp&quot;&gt;#Preview(&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;as&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;systemMedium&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;using&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;TestWidgetConfigurationIntent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;nv&quot;&gt;widget&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;TestWidget&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;nv&quot;&gt;timelineProvider&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;TestAppIntentTimelineProvider&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;p&gt;My minimal reproduction case is &lt;a href=&quot;https://gist.github.com/twocentstudios/c4fc39c5a16115b0aa1a230f69281daa&quot;&gt;in this gist&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I had to switch back to single TimelineEntry previews.&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;cp&quot;&gt;#Preview(&quot;Default&quot;, as: .accessoryRectangular, widget: {&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;StationBookmarkWidget&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;nv&quot;&gt;timeline&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;TimetableTimelineEntry&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mock&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;refreshing&quot;&gt;Refreshing&lt;/h3&gt;

&lt;p&gt;Apple has several frameworks that operate fundamentally based on vaguely documented heuristics, often partially related to the unique way each user uses their device.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BackgroundTasks&lt;/code&gt; is a good example, although it’s perhaps changed in a few years since I trialed it. It took me weeks of daily usage testing to get a feel for relevant metrics like how often a task was run by the system, how long it was run on average, how much work my task completed during that time, and what other system frameworks were available during my app’s allocated period.&lt;/p&gt;

&lt;p&gt;I’ve found widgets to be similar to that experience.&lt;/p&gt;

&lt;p&gt;My home screen and today screen widgets (of small and medium family) refresh as expected.&lt;/p&gt;

&lt;p&gt;However, since its initial implementation, my accessory medium family lock screen widget behaves unpredictably, but always incorrectly.&lt;/p&gt;

&lt;p&gt;With the same timeline provider implementation as the other widget types, the lock screen widget does not refresh at 3am. In fact, it usually does not load the day’s station schedule until somewhere between 11am-2pm.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-lock-widget-no-more-trains.jpg&quot; width=&quot;&quot; height=&quot;250&quot; alt=&quot;The lock screen widget showing the &apos;no more trains&apos; message. It should have loaded a fresh timeline at 3am.&quot; title=&quot;The lock screen widget showing the &apos;no more trains&apos; message. It should have loaded a fresh timeline at 3am.&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The lock screen widget showing the &apos;no more trains&apos; message. It should have loaded a fresh timeline at 3am.&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-lock-widget-placeholder.jpg&quot; width=&quot;&quot; height=&quot;250&quot; alt=&quot;The lock screen widget showing the placeholder configuration&quot; title=&quot;The lock screen widget showing the placeholder configuration&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The lock screen widget showing the placeholder configuration&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To be fair, there is some &lt;a href=&quot;https://developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date&quot;&gt;official documentation&lt;/a&gt; about widget refreshing, but it leaves out enough to keep me guessing as to what the underlying problem actually is.&lt;/p&gt;

&lt;p&gt;My current theory is that my widget has too many timeline entries per day.&lt;/p&gt;

&lt;p&gt;In the same vein as my &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BackgroundTasks&lt;/code&gt; framework drama above, it’s painfully slow to debug this issue because I can test only one potential fix per day &lt;em&gt;at most&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;My theory of too many timeline entries is based on a few lines from the docs:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;A widget’s budget applies to a 24-hour period. WidgetKit tunes the 24-hour window to the user’s daily usage pattern, which means the daily budget doesn’t necessarily reset at exactly midnight.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;OK, the fact that the reload doesn’t happen until mid-day is consistent with the above.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;For a widget the user frequently views, a daily budget typically includes from 40 to 70 refreshes. This rate roughly translates to widget reloads every 15 to 60 minutes, but it’s common for these intervals to vary due to the many factors involved.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I’m assuming the loaded term “refresh” refers to the system taking an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Entry&lt;/code&gt; struct and loading it into the widget’s UI at the requested schedule and &lt;em&gt;not&lt;/em&gt; the timeline provider getting a request from the system to fetch an entire fresh timeline.&lt;/p&gt;

&lt;p&gt;If that’s true, then my widget’s timeline definitely has more than 70 entries. In fact, many railway’s stations see over 250 trains per day, and thus will have that many timeline entries &lt;strong&gt;if I always want to show the user the next 6 trains&lt;/strong&gt; and no departed trains.&lt;/p&gt;

&lt;p&gt;I tested this theory by creating a build that limited the timeline to ~60 entries, basically updating every ~6 train departures. This schedule certainly treads the line between useful and not. In a few days of testing it seemed like this change resulted in the timeline reloading properly at 3am.&lt;/p&gt;

&lt;p&gt;With that knowledge, the rest of the docs make even less sense to me:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;If your widget can predict points in time that it should reload, the best approach is to generate a timeline for as many future dates as possible.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In isolation, this makes sense and is what I’m trying to do. In theory, I could generate an indefinite timeline since the station timetables are fixed.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Keep the interval of entries in the timeline as large as possible for the content you display.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For me, “as large as possible” is once every train departure, which can be every minute at minimum.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;WidgetKit imposes a minimum amount of time before it reloads a widget.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;OK, fine, understandable. Hopefully it’s doing this somewhat intelligently based on user behavior though?&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Your timeline provider should create timeline entries that are at least about 5 minutes apart.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Why though? Why can’t the system impose this limit dynamically, again, based on user behavior or whatever other factors it has?&lt;/p&gt;

&lt;p&gt;Regardless of the docs, I’m still confused as to why this failure case appears as it does:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Why am I not seeing the timeline stop updating on a random time entry instead of the “no more trains” empty state or placeholder state?&lt;/li&gt;
  &lt;li&gt;Why am I not seeing any issue with the other widget types?&lt;/li&gt;
  &lt;li&gt;Why does WidgetKit not have at least a debug accessible error for reporting “too many timeline entries”? This could even be thrown when returning the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Timeline&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few more hypotheses I have:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I’m generally more active in the evening than the morning. Perhaps the hidden device heuristics are causing the system to expend my widget’s refresh budget during the time range I have the most device pickups.&lt;/li&gt;
  &lt;li&gt;The always-on screen feature is causes the system to be too overzealous about updating the widget. According to Screen Time, I generally have about 60-80 device pickups per day. I don’t think that counts times I wake my phone (e.g. to check notifications) but don’t unlock it. If the widget loads a new entry for each pickup and pre-pickup, plus whatever other triggers it has, that would exceed the budget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A beta tester also confirmed the widget’s errant lock screen behavior on an iPhone SE, which makes me even less sure about the causal factors I’ve hypothesized.&lt;/p&gt;

&lt;p&gt;As you can probably tell, this bug has been a thorn in my side for months. I considered removing the lock screen widget from v1.0, but I figured it’d get better data for debugging, and users would still see the timetable for most of the day. The failure also doesn’t prevent users tapping through the widget to quickly see the timetable in the app.&lt;/p&gt;

&lt;h2 id=&quot;app-navigation&quot;&gt;App navigation&lt;/h2&gt;

&lt;p&gt;The navigation story for Eki Bright is straightforward since there are so few screens.&lt;/p&gt;

&lt;p&gt;There’s one navigation stack that handles all screens except the about screen, which is presented modally.&lt;/p&gt;

&lt;p&gt;The behavior of the search feature is standard iOS, but still a bit weird in my opinion.&lt;/p&gt;

&lt;p&gt;I’m still planning to keep the navigation structure as simple as possible as the app’s features continue to evolve for the sake of speed. However, I’d like to provide more dedicated resource screens (e.g. a railway screen, a station detail screen), and more links between those screens.&lt;/p&gt;

&lt;h3 id=&quot;navigation-in-tca&quot;&gt;Navigation in TCA&lt;/h3&gt;

&lt;p&gt;The improvements to navigation in TCA are welcome. There are two ways to handle stack-based navigation in TCA: manipulating the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;StackState&lt;/code&gt; programmatically and/or using the SwiftUI &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NavigationLink&lt;/code&gt; view.&lt;/p&gt;

&lt;p&gt;I chose the former, and am intercepting all navigation-related reducer actions at the root level and manipulating the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;StackState&lt;/code&gt;. (However, dismissals are handled with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dismiss&lt;/code&gt; dependency.)&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;kt&quot;&gt;Reduce&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;action&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;switch&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;action&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;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;path&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;element&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;action&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;stationDetail&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;railDirectionButtonTapped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;railDirection&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;stationDetailState&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;id&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;stationDetail&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;k&quot;&gt;none&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;append&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;stationDirections&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;station&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stationDetailState&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;station&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;initialRailDirection&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;railDirection&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;k&quot;&gt;none&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;path&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;element&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;id&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;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;action&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;nearbyStations&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;searchResultTapped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;nearbyStation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)))):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;append&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;stationDirections&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;station&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;nearbyStation&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;station&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;k&quot;&gt;none&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;path&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;element&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;id&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;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;action&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;stationDirections&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;timetableLeft&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;trainTapped&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trainTimetableID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))))):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;append&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;trainTimetable&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;trainTimetableID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;trainTimetableID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;stationOfInterest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stationID&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;k&quot;&gt;none&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;I consider this strategy to be somewhat brittle, but up to now it’s provided flexibility. The feature hierarchy is not so complicated yet as to be unwieldy.&lt;/p&gt;

&lt;h2 id=&quot;design&quot;&gt;Design&lt;/h2&gt;

&lt;p&gt;Depending on the app and feature, I occasionally do design work upfront in a pencil sketch and/or a Figma page. I also feel comfortable iterating quickly on design in a SwiftUI preview.&lt;/p&gt;

&lt;p&gt;Eki Bright evolved gradually from a prototype. And at first, I was mostly focused on the appearance of each widget family.&lt;/p&gt;

&lt;p&gt;I also planned to, and still plan to, commission a professional designer to make a full pass at least the visual design language of the app if not the UX and branding as well.&lt;/p&gt;

&lt;p&gt;However, for v1.0 I decided to play it safe with a utilitarian design focused on, you guessed it, speed. And closely related to speed, visual clarity.&lt;/p&gt;

&lt;h3 id=&quot;utilitarian-design&quot;&gt;Utilitarian design&lt;/h3&gt;

&lt;p&gt;The standard iOS design language is relatively utilitarian, so it’s not &lt;em&gt;too&lt;/em&gt; difficult to play within that sandbox if you don’t have strong opinions about visual design.&lt;/p&gt;

&lt;p&gt;There are a lot of benefits that fall out of the decision to stick to standard UI framework components and HIG guidelines:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Dark mode requires little additional work.&lt;/li&gt;
  &lt;li&gt;Dynamic type requires thought and testing, but comparatively less work.&lt;/li&gt;
  &lt;li&gt;App-wide constants like screen margins don’t need to be maintained.&lt;/li&gt;
  &lt;li&gt;There’s no additional costs for font licensing.&lt;/li&gt;
  &lt;li&gt;Programmatic component configuration is quick.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This extends to e.g. using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;List&lt;/code&gt; vs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LazyVStack&lt;/code&gt; (a decision that’s more loaded than just visual aesthetics). &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;List&lt;/code&gt; has out-of-the-box support for item moving, deleting, edit mode, sticky headers/footers, dividers, etc. Although I just as often found myself fighting system defaults when my particular layout requirements fell apart. There’s no free lunch in (iOS) dev.&lt;/p&gt;

&lt;h3 id=&quot;color&quot;&gt;Color&lt;/h3&gt;

&lt;p&gt;I defer my use of color to the railway colors from my data provider and the 5 train category colors I chose. From my experience as a user, these colors are incredibly important in quickly choosing a station from my bookmarks or an entry from the timetable.&lt;/p&gt;

&lt;p&gt;When choosing the app’s accent color (a &lt;a href=&quot;https://developer.apple.com/design/human-interface-guidelines/color#App-accent-colors&quot;&gt;HIG staple&lt;/a&gt;), I intentionally picked a purple-ish color because there are few railways that use purple branding.&lt;/p&gt;

&lt;p&gt;I do my best to create visual hierarchy with SwiftUI’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;primary&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secondary&lt;/code&gt; foreground colors combined with font sizes and bolding. It’s a tough balance, and it took constant effort iterating, but I feel pretty good about where v1.0 ended up.&lt;/p&gt;

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

&lt;p&gt;My use of animations is relatively light. The nearby list reconfigures often while moving, and these items rearrange themselves with animation. The station timetable screen scrolls naturally when the time updates. Besides that, I follow that same utilitarian mindset mentioned above.&lt;/p&gt;

&lt;h3 id=&quot;rail-direction&quot;&gt;Rail direction&lt;/h3&gt;

&lt;p&gt;The toughest UX decisions I’ve encountered so far (and which the jury’s still out on) are related to rail directions.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;How to display the rail direction name to users.&lt;/li&gt;
  &lt;li&gt;Where to put the rail direction segmented control on the station timetable screen.&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-station-timetable-direction.jpg&quot; width=&quot;&quot; height=&quot;550&quot; alt=&quot;The rail direction segmented control in the toolbar (bottom), and rail direction indicator (top)&quot; title=&quot;The rail direction segmented control in the toolbar (bottom), and rail direction indicator (top)&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The rail direction segmented control in the toolbar (bottom), and rail direction indicator (top)&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Most official rail direction names are in the dataset as 上り (inbound), 下り (outbound), 北へ (northbound), etc. However, these official names are either absent or de-emphasized in signage around stations.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-yamanote-station-signage.jpg&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;Rare signage at JR Ebisu station showing inner-loop (内回り) and outer-loop (外回り) rail directions of the Yamanote line&quot; title=&quot;Rare signage at JR Ebisu station showing inner-loop (内回り) and outer-loop (外回り) rail directions of the Yamanote line&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Rare signage at JR Ebisu station showing inner-loop (内回り) and outer-loop (外回り) rail directions of the Yamanote line&lt;/div&gt;&lt;/div&gt;

&lt;h4 id=&quot;how-to-display-the-rail-direction-name&quot;&gt;How to display the rail direction name&lt;/h4&gt;

&lt;p&gt;For v1.0 especially, I wanted to keep my own large-scale supplements to the official dataset as minimal as possible.&lt;/p&gt;

&lt;p&gt;My choice for displaying the rail direction was to:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;(A)&lt;/strong&gt; Default to showing the official direction name (“inbound”) with the awareness that users would find it jarring at first, but eventually learn and adapt for railways they used regularly (arguably the core use-case for the app).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;(B)&lt;/strong&gt; Default to replacing the official direction name with the last station in that railway. This is a common display technique seen in station signage, although sometimes the &lt;em&gt;next&lt;/em&gt; station or the &lt;em&gt;next major&lt;/em&gt; station are used instead.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;(C)&lt;/strong&gt; Default to replacing the official direction name with my own custom choice based on first-hand research of each railway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I chose (A), using the official direction name out of pragmatism.&lt;/p&gt;

&lt;p&gt;In user testing, this has continued to be a pain point, and will certainly be a high-priority item in the TODO list.&lt;/p&gt;

&lt;h4 id=&quot;where-to-put-the-rail-direction-segmented-control&quot;&gt;Where to put the rail direction segmented control&lt;/h4&gt;

&lt;p&gt;The (optional, depending on the station and railway) segmented control for the rail direction affects all the contents of the station timetable screen besides the station name in the navigation bar, the railway name, and the schedule.&lt;/p&gt;

&lt;p&gt;Considering hierarchy, it should either be in the navigation bar or the toolbar.&lt;/p&gt;

&lt;p&gt;The navigation bar is already full with the title, especially when used in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NavigationBarItem.TitleDisplayMode.large&lt;/code&gt;. However, it could still be condensed into the trailing bar item.&lt;/p&gt;

&lt;p&gt;My instinct was to put the segmented control in the toolbar. This makes it easier to tap without reaching, and also filling the space and disappearing cleanly when it’s not relevant for the station. I supplemented it with a smaller label in the section header since it’s important information.&lt;/p&gt;

&lt;p&gt;However, in user testing, some users were completely blind to the segmented control in the toolbar position. If users don’t notice it, they’ll also be unaware they can swipe left and right to page between timetables for each rail direction.&lt;/p&gt;

&lt;p&gt;It is crucially important users don’t miss this control. When coming from a nearby or search result, the user will navigate to the default rail direction. If they don’t realize they need to navigate further to see the opposite direction, they may use incorrect timeline information or spend time searching the interface.&lt;/p&gt;

&lt;p&gt;Perhaps the simplest solution is to add a tooltip during onboarding to call this out. I’m generally pretty skeptical of making discovery of any critical feature reliant on users reading text.&lt;/p&gt;

&lt;p&gt;For v1.0 it was a necessary evil to ship the toolbar interface and further measure opinions.&lt;/p&gt;

&lt;p&gt;Short term, I should add a tap action to the rail direction label in the section header that transitions to the other rail direction timetable.&lt;/p&gt;

&lt;p&gt;Medium term, I’m planning on de-emphasizing the full station timetable screen and replacing it in the navigation hierarchy with a station detail screen. When designing the detail screen, I can be more careful about highlighting the rail direction distinction.&lt;/p&gt;

&lt;h3 id=&quot;yamanote-train-destination&quot;&gt;Yamanote train destination&lt;/h3&gt;

&lt;p&gt;Related to rail direction is the destination station of a particular train. In the simple case, railways always end at the same station. But sometimes a railway breaks off into other railways that end at different stations.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-train-destination-nakame.jpg&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;The Toyoko line from Nakameguro continues on to a few different stations depending on the train&quot; title=&quot;The Toyoko line from Nakameguro continues on to a few different stations depending on the train&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;The Toyoko line from Nakameguro continues on to a few different stations depending on the train&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The Yamanote loop line (as my constant source of edge cases) presented a user clarity issue compounded by the aforementioned “how to display the rail direction name” woes.&lt;/p&gt;

&lt;p&gt;The Yamanote line trains always start and end at Osaki station. That means every timetable for every Yamanote line station and rail direction showed Osaki as the destination.&lt;/p&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-destination-yamanote-before.jpg&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;Both inner-loop and outer-loop trains show a destination of Osaki 大崎&quot; title=&quot;Both inner-loop and outer-loop trains show a destination of Osaki 大崎&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Both inner-loop and outer-loop trains show a destination of Osaki 大崎&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In my use I found train destination is a good backup when rail direction was unclear. Without this affordance, the mental gymnastics I needed to do to understand inner-loop and outer-loop directions was a deal-breaker for me.&lt;/p&gt;

&lt;p&gt;I decided to spend a little time manually adding “direction stations” for each station on the Yamanote line only. I consulted signage for each station and chose the next most identifiable station.&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;k&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;directionStations&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;StationDirectionPair&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;Station&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;ID&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;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;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Osaki&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railDirection&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;innerLoop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Shinagawa&quot;&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;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Gotanda&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railDirection&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;innerLoop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Shinagawa&quot;&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;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Meguro&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railDirection&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;innerLoop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Shinagawa&quot;&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;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Ebisu&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railDirection&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;innerLoop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Shinagawa&quot;&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;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Shibuya&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railDirection&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;innerLoop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Shinagawa&quot;&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;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Harajuku&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railDirection&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;innerLoop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Shibuya&quot;&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;stationID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Yoyogi&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;railDirection&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;innerLoop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;JR-East.Yamanote.Shibuya&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;caption-wrapper&quot;&gt;&lt;img class=&quot;caption&quot; src=&quot;/images/eki-bright-destination-yamanote-after.jpg&quot; width=&quot;&quot; height=&quot;350&quot; alt=&quot;Yamanote train destinations (Shibuya 渋谷 and Shinagawa 品川) are now more relevant and make rail directions more differentiable at a glance&quot; title=&quot;Yamanote train destinations (Shibuya 渋谷 and Shinagawa 品川) are now more relevant and make rail directions more differentiable at a glance&quot; /&gt;&lt;div class=&quot;caption-text&quot;&gt;Yamanote train destinations (Shibuya 渋谷 and Shinagawa 品川) are now more relevant and make rail directions more differentiable at a glance&lt;/div&gt;&lt;/div&gt;

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

&lt;p&gt;I covered a lot in this post. This is a decent summary of the many problems I worked through to varying degrees of “solved” during the few months of development leading up to v1.0 of Eki Bright.&lt;/p&gt;

&lt;p&gt;As I become more confident in my solution to some of the above problems, I’ll try to write some more straightforward “How to X in Swift” posts in the future.&lt;/p&gt;

&lt;p&gt;Until then, thanks for reading.&lt;/p&gt;
</description>
        <pubDate>Tue, 06 Aug 2024 08:02:00 -0500</pubDate>
        <link>https://twocentstudios.com/2024/08/06/eki-bright-developing-the-app-for-ios/</link>
        <guid isPermaLink="true">https://twocentstudios.com/2024/08/06/eki-bright-developing-the-app-for-ios/</guid>
        
        <category>apple</category>
        
        <category>ios</category>
        
        <category>ekibright</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>
