<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Odd Eggs Studio</title>
    <description>The latest articles on DEV Community by Odd Eggs Studio (@oddeggs).</description>
    <link>https://dev.to/oddeggs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4198300%2F87c12a49-520e-47ce-a6c6-ecf0295824a8.png</url>
      <title>DEV Community: Odd Eggs Studio</title>
      <link>https://dev.to/oddeggs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oddeggs"/>
    <language>en</language>
    <item>
      <title>Vietnam's 2027 public holidays, in money (and the pot maths behind it)</title>
      <dc:creator>Odd Eggs Studio</dc:creator>
      <pubDate>Sun, 08 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/oddeggs/vietnams-2027-public-holidays-in-money-and-the-pot-maths-behind-it-581k</link>
      <guid>https://dev.to/oddeggs/vietnams-2027-public-holidays-in-money-and-the-pot-maths-behind-it-581k</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from &lt;a href="https://at6pm.app/blog/vietnam-public-holidays-2027-budget" rel="noopener noreferrer"&gt;the 6pm blog&lt;/a&gt;. We make 6pm, an iPhone budgeting app built in Saigon, and this is the guide we wrote for our Vietnamese users' 2027 holidays. If you build anything that turns a date into a monthly amount, there's a short section near the end on how the app does that maths in TypeScript, integer đồng and all.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F091zf1de9hrpykgjq9pq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F091zf1de9hrpykgjq9pq.jpg" alt="The 6pm Plan screen: money coming in, bills, pots, Future you and free money for the days left" width="720" height="1565"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Between now and the end of 2027, Vietnam has six public holiday breaks besides Tết: 24 November 2026, 1 January, Hùng Kings' Day on 16 April, 30 April–1 May, 2 September and 24 November 2027. For Tết 2027, civil servants are off for seven days, Thursday 4 to Wednesday 10 February. With the example budgets below, the five breaks of 2027 outside Tết cost 9,900,000₫, or 825,000₫ a month on average. But 30 April comes early: start from the 10 November payday and the first six months need 1,100,000₫ each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vietnam's 2027 holiday calendar (plus 24 November this year)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Holiday&lt;/th&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Days off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vietnamese Culture Day&lt;/td&gt;
&lt;td&gt;Tue 24 Nov 2026&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Official.&lt;/strong&gt; 1 day, the first time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New Year's Day&lt;/td&gt;
&lt;td&gt;Fri 1 Jan 2027&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Set by law.&lt;/strong&gt; 1 day; a three-day weekend if your weekend is Saturday and Sunday&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tết (Lunar New Year)&lt;/td&gt;
&lt;td&gt;Thu 4 – Wed 10 Feb 2027&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Official.&lt;/strong&gt; Civil servants 7 days; businesses at least 5 paid days, on one of three options the employer picks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hùng Kings' Day (10th of the 3rd lunar month)&lt;/td&gt;
&lt;td&gt;Fri 16 Apr 2027&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Set by law.&lt;/strong&gt; 1 day; a three-day weekend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reunification Day and Labour Day&lt;/td&gt;
&lt;td&gt;Fri 30 Apr – Sat 1 May 2027&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Set by law.&lt;/strong&gt; 2 holidays; with Saturdays off, 1 May's day moves to Mon 3 May, so &lt;strong&gt;expected&lt;/strong&gt; 4 days, 30 Apr – 3 May&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;National Day&lt;/td&gt;
&lt;td&gt;Thu 2 – Sun 5 Sep 2027&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Official.&lt;/strong&gt; Civil servants 4 days; businesses give 2 Sep plus 1 or 3 Sep, at the employer's choice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vietnamese Culture Day&lt;/td&gt;
&lt;td&gt;Wed 24 Nov 2027&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Set by law.&lt;/strong&gt; 1 day&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Official&lt;/strong&gt; means there is a document. In &lt;a href="https://datafiles.chinhphu.vn/cpp/files/vbpq/2026/9/10065_vpcp-kgvx_02102026_1-signed.signed.pdf" rel="noopener noreferrer"&gt;letter 10065/VPCP-KGVX of 2 October 2026&lt;/a&gt;, the Government Office approved seven days off for Tết and four for National Day in 2027, and one day for Vietnamese Culture Day in 2026. The dates are in the Ministry of Home Affairs' notice 11483/TB-BNV of 9 October 2026, as &lt;a href="https://hatinh.gov.vn/vi/bai-viet/tet-nguyen-dan-2027-cong-chuc-vien-chuc-duoc-nghi-7-ngay-lien-tuc" rel="noopener noreferrer"&gt;quoted by Hà Tĩnh's provincial portal&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set by law&lt;/strong&gt; means the day off is already in Article 112 of the 2019 Labour Code (1 January, Hùng Kings' Day, 30 April, 1 May) or in Resolution 28/2026/QH16 (24 November every year). A holiday that lands on your weekly day off moves to the next working day under Article 111. No document yet swaps working days or adds days around these dates, so every longer break here is expected, not official. The &lt;a href="https://at6pm.app/blog/vietnam-public-holidays-2027-budget" rel="noopener noreferrer"&gt;original page on at6pm.app&lt;/a&gt; will be updated when a notice comes out.&lt;/p&gt;

&lt;p&gt;The calendar above is the civil service one. Private employers choose their Tết days and the extra National Day, and announce them ahead. Shift and service workers may work on the holiday itself. Ask HR for your schedule before you book.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each break costs (example numbers)
&lt;/h2&gt;

&lt;p&gt;These are example numbers for one person working in Vietnam, travelling alone or with costs already split with whoever comes along. Swap in your own prices; what matters is having a number for each break.&lt;/p&gt;

&lt;h3&gt;
  
  
  24 November 2026: a day in the city, 300,000₫
&lt;/h3&gt;

&lt;p&gt;Cinema ticket 100,000₫, dinner 150,000₫, coffee 50,000₫: &lt;strong&gt;300,000₫&lt;/strong&gt;. It's three weeks away, so this one comes out of this month's money, no pot needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  1 January 2027: three days at a nearby beach, 1,500,000₫
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Return bus&lt;/td&gt;
&lt;td&gt;400,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Room, 1 night&lt;/td&gt;
&lt;td&gt;600,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Food&lt;/td&gt;
&lt;td&gt;500,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,500,000₫&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Tết 2027: its own plan
&lt;/h3&gt;

&lt;p&gt;Tết is the year's biggest line and has its own maths: tickets home, gifts, lucky money, divided by the paychecks left. The Vietnamese guide &lt;a href="https://at6pm.app/vi/blog/ke-hoach-chi-tieu-tet-thuong-li-xi" rel="noopener noreferrer"&gt;Tết 2027, thưởng và lì xì&lt;/a&gt; walks through it, and &lt;a href="https://at6pm.app/blog/holiday-spending-plan" rel="noopener noreferrer"&gt;the holiday spending plan&lt;/a&gt; does the same for any big season. The rest of this page leaves Tết out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hùng Kings' Day, 16 April: three days back home, 1,500,000₫
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Return bus&lt;/td&gt;
&lt;td&gt;500,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gifts for the family&lt;/td&gt;
&lt;td&gt;400,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Food, coffee with old friends&lt;/td&gt;
&lt;td&gt;600,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,500,000₫&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  30 April – 3 May (four days, expected): the big trip, 3,600,000₫
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Return ticket at peak season&lt;/td&gt;
&lt;td&gt;1,200,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Room, 3 nights × 500,000₫&lt;/td&gt;
&lt;td&gt;1,500,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Food and entry tickets&lt;/td&gt;
&lt;td&gt;900,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,600,000₫&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The busiest break of the year. Book the ticket and the room early and the first two lines stop being guesses.&lt;/p&gt;

&lt;h3&gt;
  
  
  2 September: two or four days, 3,000,000₫
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Return ticket&lt;/td&gt;
&lt;td&gt;1,000,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Room, 2 nights × 600,000₫&lt;/td&gt;
&lt;td&gt;1,200,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Food&lt;/td&gt;
&lt;td&gt;800,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,000,000₫&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Civil servants are off Thursday 2 to Sunday 5 September. If your employer picks 2 and 3 September, you get four days too, as long as your weekend is Saturday and Sunday; if it picks 1 and 2 September, Friday is a work day and the trip gets shorter.&lt;/p&gt;

&lt;h3&gt;
  
  
  24 November 2027: a Wednesday, 300,000₫
&lt;/h3&gt;

&lt;p&gt;One day mid-week is hard to travel on. Repeat this year's: cinema, dinner, coffee, &lt;strong&gt;300,000₫&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole of 2027, in money
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Break&lt;/th&gt;
&lt;th&gt;Example budget&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 Jan&lt;/td&gt;
&lt;td&gt;1,500,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 Apr&lt;/td&gt;
&lt;td&gt;1,500,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 Apr–3 May&lt;/td&gt;
&lt;td&gt;3,600,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 Sep&lt;/td&gt;
&lt;td&gt;3,000,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24 Nov&lt;/td&gt;
&lt;td&gt;300,000₫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9,900,000₫&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Divided by 12: &lt;strong&gt;825,000₫ a month&lt;/strong&gt;. That is what "every break, every year" costs on average with the examples above.&lt;/p&gt;

&lt;h2&gt;
  
  
  825,000₫ is the average. 30 April sets the pace
&lt;/h2&gt;

&lt;p&gt;The holidays don't wait for an even split. If you're paid on the 10th and start with the 10 November 2026 paycheck, there are only six paydays before 30 April (10 Nov, 10 Dec, 10 Jan, 10 Feb, 10 Mar, 10 Apr), and 1 January, Hùng Kings' Day and 30 April already need 6,600,000₫ between them. At 825,000₫ a month you'd have 4,950,000₫ by 30 April, 1,650,000₫ short.&lt;/p&gt;

&lt;p&gt;Split by stretch, it works out on time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Paydays&lt;/th&gt;
&lt;th&gt;For&lt;/th&gt;
&lt;th&gt;Needed&lt;/th&gt;
&lt;th&gt;Each month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10 Nov 2026 – 10 Apr 2027 (6)&lt;/td&gt;
&lt;td&gt;1 Jan, 16 Apr, 30 Apr&lt;/td&gt;
&lt;td&gt;6,600,000₫&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,100,000₫&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 May – 10 Aug 2027 (4)&lt;/td&gt;
&lt;td&gt;2 Sep&lt;/td&gt;
&lt;td&gt;3,000,000₫&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;750,000₫&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 Sep – 10 Nov 2027 (3)&lt;/td&gt;
&lt;td&gt;24 Nov&lt;/td&gt;
&lt;td&gt;300,000₫&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100,000₫&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total: 13 paydays&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9,900,000₫&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Check each stop: by 1 January you've put away 2,200,000₫; the beach takes 1,500,000₫, leaving 700,000₫. By Hùng Kings' Day all six paydays are in, so 5,100,000₫ is there; the trip home takes 1,500,000₫, leaving exactly 3,600,000₫ for 30 April.&lt;/p&gt;

&lt;p&gt;The first six months are the heavy stretch, and they overlap with saving for Tết. If 1,100,000₫ a month is too tight, each fix has a number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shrink 30 April&lt;/strong&gt;: a 2,400,000₫ trip instead of 3,600,000₫ makes the first stretch (1.5 + 1.5 + 2.4 million) ÷ 6 = 900,000₫ a month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay for 1 January from December's money&lt;/strong&gt;, like 24 November: the first stretch drops to (1.5 + 3.6 million) ÷ 6 = 850,000₫ a month, and December gets tighter instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Take fewer trips&lt;/strong&gt;: drop one break and share its money among the rest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not paid on the 10th? Count your own paydays before each break; the division is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start a pot this payday
&lt;/h2&gt;

&lt;p&gt;In 6pm, money set aside for something is a &lt;strong&gt;pot&lt;/strong&gt;, with a goal and the month you need it. A pot takes its share before your daily number is worked out: &lt;strong&gt;(goal − what you already have) ÷ months left until that month&lt;/strong&gt;, rounded up to the nearest 50,000₫. 6pm wants the money ready before the trip's month begins, so it plans a little tighter than the table above. Made before the 10 November payday:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A "Beach, 1 Jan" pot, 1,500,000₫, January 2027: 1,500,000 ÷ 2 = &lt;strong&gt;750,000₫&lt;/strong&gt; a month.&lt;/li&gt;
&lt;li&gt;A "30 April" pot, 3,600,000₫, April 2027: 3,600,000 ÷ 5 = 720,000₫, rounded up to &lt;strong&gt;750,000₫&lt;/strong&gt; a month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a 30-day pay cycle, the 30 April pot takes 25,000₫ a day off what's safe to spend today. Seeing that 25,000₫ now is easier than putting the trip on a card in April and paying it off in May.&lt;/p&gt;

&lt;p&gt;The free plan includes one pot of your own: enough for the biggest trip, with the small breaks coming out of your daily number. 6pm Max makes pots unlimited, one per break and one for Tết. For how the daily number is worked out, see &lt;a href="https://at6pm.app/blog/how-much-can-i-spend-per-day" rel="noopener noreferrer"&gt;how much can I spend per day&lt;/a&gt;. 6pm never moves money: the amounts in a pot are what you say you've set aside. It just counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  For builders: the pot maths in code
&lt;/h2&gt;

&lt;p&gt;The numbers above come from a few small pure functions in &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;our open-source repo&lt;/a&gt; (Apache 2.0, in &lt;code&gt;packages/domain/src/plan/&lt;/code&gt; and &lt;code&gt;money/&lt;/code&gt;). Money is stored as integer đồng, so there's no float drift to round away. An occasion pot spreads what's left over the months to its date, rounds &lt;strong&gt;up&lt;/strong&gt; to a 50,000₫ step, and never asks for more than is left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;potMonthlyNeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;goal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Vnd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Vnd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;monthsLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Currency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;VND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Vnd&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;goal&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;monthsLeft&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;monthsLeft&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;ceilToSetAside&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;monthsLeft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/** Ceils to fifty steps, as pot set-asides do (50.000₫ in Vietnam, $50 in the US). */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ceilToSetAside&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Money&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two choices did most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Months are counted from the pay cycle, not from today.&lt;/strong&gt; A cycle is named after the month that holds the day before its next payday, so the cycle that ends on the 10 November payday is November's. That's why a pot made before 10 November counts January as 2 months away, and the set-aside doesn't jump around between paydays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Round up, then cap.&lt;/strong&gt; Rounding 720,000₫ up to 750,000₫ means the pot fills a little early instead of a little late, and the &lt;code&gt;Math.min(left, ...)&lt;/code&gt; keeps the last month from overshooting the goal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  On the trip, just travel
&lt;/h2&gt;

&lt;p&gt;A good plan doesn't make you type receipts while looking at the sea.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restaurants and entry tickets with a VietQR code&lt;/strong&gt;: scan it with 6pm, then pay in your bank app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cash at the market&lt;/strong&gt;: say "bánh mì thirty thousand", or Quick add, three taps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hotel bill&lt;/strong&gt;: take a photo and 6pm reads the amount.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Didn't open the app all trip? Nothing breaks. Missed days wait for you, and what you spent already counts. Back home, close them in one go.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How many days off is Tết 2027 in Vietnam?
&lt;/h3&gt;

&lt;p&gt;Civil servants are off seven days, Thursday 4 to Wednesday 10 February 2027 (the first day of Tết is Saturday 6 February). Private-sector workers get at least five paid Tết days; the employer picks one of three options and announces it ahead.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many days off are 30 April and 1 May 2027?
&lt;/h3&gt;

&lt;p&gt;By law, two holidays: Friday 30 April and Saturday 1 May. If Saturday is your day off, 1 May's day moves to Monday 3 May, so the expected break is four days, 30 April to 3 May. No notice covers this break yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many days off is National Day 2027?
&lt;/h3&gt;

&lt;p&gt;Civil servants are off four days, Thursday 2 to Sunday 5 September 2027. Businesses give two: 2 September plus 1 or 3 September, at the employer's choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is 24 November a public holiday every year?
&lt;/h3&gt;

&lt;p&gt;Yes. Resolution 28/2026/QH16 makes 24 November Vietnamese Culture Day every year, a paid day off. The first is Tuesday 24 November 2026; in 2027 it falls on a Wednesday.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I make a trip pot on 6pm's free plan?
&lt;/h3&gt;

&lt;p&gt;Yes. Free includes one pot of your own; 6pm Max makes them unlimited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try 6pm
&lt;/h2&gt;

&lt;p&gt;6pm is on &lt;a href="https://apps.apple.com/app/id6504689878" rel="noopener noreferrer"&gt;the App Store&lt;/a&gt; for iPhone, and the code is on &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. More at &lt;a href="https://at6pm.app" rel="noopener noreferrer"&gt;at6pm.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you've built savings goals or sinking funds into an app: did you round the monthly amount up, down, or to the nearest step, and did your users notice?&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>typescript</category>
      <category>productivity</category>
      <category>personalfinance</category>
    </item>
    <item>
      <title>Sky shaders at 120fps in React Native with Skia</title>
      <dc:creator>Odd Eggs Studio</dc:creator>
      <pubDate>Wed, 04 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/oddeggs/sky-shaders-at-120fps-in-react-native-with-skia-8dh</link>
      <guid>https://dev.to/oddeggs/sky-shaders-at-120fps-in-react-native-with-skia-8dh</guid>
      <description>&lt;p&gt;6pm's sky is one React Native Skia canvas drawn behind the Today screen: a time-of-day gradient, drifting blooms, sun, halftone, grain, rain and three "living scenes". It stays cheap by baking anything expensive once (blurs, noise, rain), keeping per-frame work to gradients and textures, and stepping slow motion at 30 Hz while scrolling still moves every frame.&lt;/p&gt;

&lt;p&gt;The code is in &lt;code&gt;apps/mobile/src/ui/sky&lt;/code&gt; in the &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;6pm repo&lt;/a&gt;. Versions: &lt;code&gt;@shopify/react-native-skia&lt;/code&gt; 2.13.1, &lt;code&gt;react-native-reanimated&lt;/code&gt; 4.7.0, Expo 58, React Native 0.88.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it started: a sky made of CSS
&lt;/h2&gt;

&lt;p&gt;6pm's design is an HTML prototype. Its sky is a stack of absolutely positioned divs: a three-stop linear gradient, three radial "blooms" softened with &lt;code&gt;filter: blur()&lt;/code&gt;, a sun with rings and a flare, an SVG &lt;code&gt;feTurbulence&lt;/code&gt; grain tile with &lt;code&gt;mix-blend-mode: overlay&lt;/code&gt;, a halftone made of a tiled &lt;code&gt;radial-gradient&lt;/code&gt;, light leaks, a vignette, and per-theme extras. Every colour is computed for the minute of the day, and changes ease over one second.&lt;/p&gt;

&lt;p&gt;CSS layer stacks don't port to views. Stacking a dozen full-screen blurred, blended views would melt the GPU and still not blend the way the browser does. Our technical decisions doc puts it in one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sky and atmosphere: &lt;code&gt;@shopify/react-native-skia&lt;/code&gt;: gradients, blurred blooms, grain/halftone/rain as runtime shaders. 60/120 fps full-screen effects; CSS layer stacks do not port to views.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The shipped code is more conservative than "everything is a shader". Here is what actually runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Colours: run the prototype's own logic
&lt;/h2&gt;

&lt;p&gt;Before drawing anything, the colours have to match. Instead of re-implementing the prototype's palette maths, &lt;code&gt;tools/design/extract-prototype-logic.ts&lt;/code&gt; extracts the prototype's look functions into a generated module, and &lt;code&gt;@6pm/theme&lt;/code&gt; runs them with the chosen look injected as state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/theme/src/look-engine.ts&lt;/span&gt;
&lt;span class="cm"&gt;/** The colour tokens of one frame. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;paletteFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FrameInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Palette&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;look&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serious&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;look&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The design's oklch tints are converted to sRGB hex in code, which renders identically to the browser references. Same function, same numbers, no "close enough".&lt;/p&gt;

&lt;h2&gt;
  
  
  One canvas, one array of numbers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Sky&lt;/code&gt; (&lt;code&gt;sky.tsx&lt;/code&gt;) is a single &lt;code&gt;&amp;lt;Canvas&amp;gt;&lt;/code&gt; with layers in prototype order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Canvas&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;StyleSheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;absoluteFill&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Group&lt;/span&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;shrink&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SkyGradient&lt;/span&gt; &lt;span class="na"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LivingBackdrop&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AtmBoxes&lt;/span&gt; &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;atm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;back&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blooms&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SkyBlooms&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* sun, flare, moon, bokeh, halftone, leak, weather, scenes, vignette */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;shown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grain&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SkyGrain&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WeatherTop&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Canvas&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is &lt;code&gt;frame&lt;/code&gt;. Everything on the sky that changes with the hour (gradient stops, bloom colours, sun opacity, halftone ink, grain strength) is packed into one flat &lt;code&gt;number[]&lt;/code&gt;, with colours taking four slots (&lt;code&gt;sky-slots.ts&lt;/code&gt;). A new hour is then a single UI-thread tween of that array, the equivalent of CSS's &lt;code&gt;transition: 1s&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/ui/sky/use-switched-tween.ts (trimmed)&lt;/span&gt;
&lt;span class="nf"&gt;useAnimatedReaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;onValues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offValues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;shared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;shared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reduced&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;withTiming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TRANSITION&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two prepared states (sun shown on Today, hidden on other tabs) live on the UI thread, so a tab switch tweens the sky without a single React render. Each layer reads its slots with &lt;code&gt;useDerivedValue&lt;/code&gt;, and Skia picks up shared values directly as props.&lt;/p&gt;

&lt;p&gt;Layers whose strength is zero for the current look and hour are unmounted (&lt;code&gt;useShown&lt;/code&gt; keeps one for an extra second so it can fade out), so a frame only pays for what is visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blooms: bake the blur into the gradient
&lt;/h2&gt;

&lt;p&gt;The prototype's blooms are radial gradients blurred with &lt;code&gt;filter: blur()&lt;/code&gt;. A live Gaussian blur on three full-screen layers every frame is exactly what we wanted to avoid.&lt;/p&gt;

&lt;p&gt;The observation that makes it free: blurring a radially symmetric shape keeps it radially symmetric. So the blurred result is itself a radial gradient, and we can compute its alpha profile once, ahead of time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/ui/sky/blur-profile.ts (trimmed)&lt;/span&gt;
&lt;span class="c1"&gt;// In polar form the 2-D Gaussian blur of a radial f is the Hankel-type integral&lt;/span&gt;
&lt;span class="c1"&gt;// g(r) = ∫ f(ρ)·(ρ/σ²)·exp(−(r−ρ)²/2σ²)·[e^(−x)·I₀(x)] dρ with x = rρ/σ² (Simpson's rule).&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blurredAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;STEPS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rho&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;STEPS&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kernel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rho&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;s2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rho&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;scaledBesselI0&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rho&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;s2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rho&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;kernel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scaledBesselI0&lt;/code&gt; is the exponentially scaled modified Bessel function, from the Abramowitz and Stegun polynomial approximations, so it doesn't overflow for large arguments. The profile is sampled as 32 stops out to &lt;code&gt;support + 3.5σ&lt;/code&gt; and memoised per look. At draw time a bloom is a plain &lt;code&gt;&amp;lt;RadialGradient&amp;gt;&lt;/code&gt; whose colours come from the frame array and whose position drifts with the clock. All three blooms composite in one full-screen pass, then blend onto the sky (screen after dark, multiply by day) like the prototype's bloom container. It has unit tests (&lt;code&gt;blur-profile.test.ts&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The base gradient itself is drawn with &lt;code&gt;dither&lt;/code&gt;, because a night sky spans only a dozen 8-bit steps over the whole screen and bands without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Halftone: a real runtime shader
&lt;/h2&gt;

&lt;p&gt;The halftone band is a tiled &lt;code&gt;radial-gradient(circle, dot 0.9px, transparent 1.5px)&lt;/code&gt; under a linear mask. This is a natural fit for a runtime effect, written in SkSL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/ui/sky/sky-halftone.tsx&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HALFTONE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Skia&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RuntimeEffect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
uniform float4 ink;
uniform float cell;
uniform float height;

half4 main(float2 p) {
  float2 q = mod(p, cell) - 0.5 * cell;
  float coverage = clamp((1.5 - length(q)) / 0.6, 0.0, 1.0);
  float y = p.y / height;
  float mask = y &amp;lt; 0.45 ? y / 0.45 : (y &amp;lt; 0.6 ? 1.0 : (1.0 - y) / 0.4);
  float a = ink.a * coverage * clamp(mask, 0.0, 1.0);
  return half4(ink.rgb * a, a);
}
`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ink&lt;/code&gt; uniform comes straight from the frame array through &lt;code&gt;useDerivedValue&lt;/code&gt;, so the dots change colour with the hour on the UI thread. Note the premultiplied return: Skia expects premultiplied colour from a shader.&lt;/p&gt;

&lt;p&gt;The other runtime effects live in &lt;code&gt;scenes/boxes/box-fill.tsx&lt;/code&gt;: one repeats a child shader on a grid (CSS &lt;code&gt;background-repeat&lt;/code&gt;), and one applies an SVG &lt;code&gt;feColorMatrix&lt;/code&gt; to noise in straight (unpremultiplied) colour, which is how two looks get their speckle and brush textures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grain: same noise as the browser, drawn once
&lt;/h2&gt;

&lt;p&gt;The prototype's grain is an SVG &lt;code&gt;feTurbulence&lt;/code&gt; tile. Skia's &lt;code&gt;FractalNoise&lt;/code&gt; is the same algorithm, so with the same frequency, octaves, seed and stitch size it produces the same grain. The subtle part is stitching: SVG's filter region overhangs the 180 pt tile by 10% on each side, so the stitch size is 216, not 180. And &lt;code&gt;feTurbulence&lt;/code&gt; runs in linearRGB, hence &lt;code&gt;LinearToSRGBGamma&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/ui/sky/sky-grain.tsx (trimmed)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STITCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;216&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;useTexture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Group&lt;/span&gt; &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Rect&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;TILE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;TILE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FractalNoise&lt;/span&gt; &lt;span class="na"&gt;freqX&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;freqY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;octaves&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
                    &lt;span class="na"&gt;tileWidth&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;STITCH&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;tileHeight&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;STITCH&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LinearToSRGBGamma&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Rect&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TILE&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TILE&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So grain is not computed per frame at all: the tile is rendered once at the screen's pixel density and repeated with an &lt;code&gt;ImageShader&lt;/code&gt;. The prototype's stepped &lt;code&gt;grain&lt;/code&gt; animation jumps between three offsets every 0.8 s, and the canvas only redraws when the step changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only a new step moves the grain: the canvas stays idle between steps when nothing else moves.&lt;/span&gt;
&lt;span class="nf"&gt;useAnimatedReaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;grainStep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;stepAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rain: bake the strokes, slide the texture
&lt;/h2&gt;

&lt;p&gt;Rain in the prototype is a tilted, oversized layer with an SVG stroke tile, one sharp and fast, one bigger, blurred and slower. Stroking and blurring a screenful of lines every frame on the CPU is not an option. So each layer's tile is painted once into an offscreen &lt;code&gt;Skia.Surface&lt;/code&gt;, blur included, and drawn as a repeating image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/ui/sky/scenes/weather/rain-bake.ts (trimmed)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;surface&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Skia&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Surface&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pixels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;pixels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCanvas&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;strokeGroups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tile&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Skia&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Make&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strokes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;tile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;tile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;tile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;tile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... stroke paint, plus a blur mask filter for the soft layer&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makeImageSnapshot&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;pixels&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 3×3 loop paints each stroke in the neighbouring cells too, so strokes and blur that cross an edge wrap around and the tile repeats without a seam. Baked tiles are cached by tile, size, blur and scale. Per frame, the only work is a transform: the layer is rotated about the screen centre and its offset slides down, looping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The living scenes
&lt;/h2&gt;

&lt;p&gt;Three premium looks have scenes that respond to the phone itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long Exposure&lt;/strong&gt; adds a warm radial band across the horizon on top of its sky gradient, and its star trails are rings masked by conic gradients whose arc grows with an "exposure" value (&lt;code&gt;calc(var(--exp, 1) * 18.3deg + 0.4deg)&lt;/code&gt; in the prototype's CSS, parsed into &lt;code&gt;k × exp + c&lt;/code&gt; in &lt;code&gt;box-css.ts&lt;/code&gt; and evaluated in a worklet).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paper Diorama&lt;/strong&gt; is layers of cut-paper hills. Each layer casts a drop shadow from the union of its pieces (&lt;code&gt;PathOp&lt;/code&gt;), so overlapping hills shade once, and the shadow shifts with tilt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lantern River&lt;/strong&gt; hangs two catenary wires of 25 bulbs each, with five silk lanterns swinging from each wire, over a river cut into 2 pt lines that ripple.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The motion comes from &lt;code&gt;useAnimatedSensor(SensorType.GRAVITY)&lt;/code&gt;. A small worklet converts gravity into the browser's &lt;code&gt;deviceorientation&lt;/code&gt; angles the prototype was written against, eases the tilt in, tracks stillness, and drives a damped pendulum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/ui/sky/scenes/boxes/scene-motion.tsx (trimmed)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gamma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gamma&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp1&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.09&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// stillness builds while the phone is held still and any movement spends it&lt;/span&gt;
&lt;span class="nx"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.12&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.88&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;still&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pendV&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.016&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pendV&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mf"&gt;0.965&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pendV&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hold the phone still and the long exposure "develops"; tilt it and the lanterns swing. The driver is only mounted for a premium look with its "sense" switch on, outside serious mode and outside previews.&lt;/p&gt;

&lt;h2&gt;
  
  
  About that 120
&lt;/h2&gt;

&lt;p&gt;The honest version of the title: the canvas can redraw at the display's full rate, and does when you scroll (the sun, flare, halftone and bokeh move with the list at their own parallax rates, every frame). But the slow motion does not need 120 updates a second, so the sky's clock deliberately steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/ui/sky/sky.tsx&lt;/span&gt;
&lt;span class="cm"&gt;/** The sky's slow drifts step 30 times a second; falling rain needs 60 to read as a fall. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DRIFT_STEP_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RAIN_STEP_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;useSkyClock&lt;/code&gt; accumulates frame time on the UI thread and only advances when a step is due, so every layer driven by &lt;code&gt;time&lt;/code&gt; is recomputed and redrawn only on a step. Drifts, pulses and twinkles look the same at 30 steps a second, and when nothing is scrolling most display frames draw nothing new.&lt;/p&gt;

&lt;p&gt;The clock is also gated. &lt;code&gt;useGatedFrameCallback&lt;/code&gt; really stops the frame callback (&lt;code&gt;setActive(false)&lt;/code&gt;) when the screen isn't showing: another tab, a full-screen takeover, or the app in the background. Nothing wakes the UI thread for a sky nobody can see. Under Reduce Motion the clock stays at zero, which freezes every drift, twinkle and grain step on its first frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can React Native Skia run custom shaders?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;code&gt;Skia.RuntimeEffect.Make&lt;/code&gt; compiles SkSL, and a &lt;code&gt;&amp;lt;Shader&amp;gt;&lt;/code&gt; with uniforms from a Reanimated &lt;code&gt;useDerivedValue&lt;/code&gt; updates on the UI thread without React renders.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you get a cheap Gaussian blur in Skia on mobile?
&lt;/h3&gt;

&lt;p&gt;Avoid a live blur where you can. For a radially symmetric shape, compute the blurred radial profile once and draw it as gradient stops; for textures like rain, paint the blur into an offscreen surface once and repeat the image.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does an animated background drain the battery?
&lt;/h3&gt;

&lt;p&gt;It costs more than a static one, so 6pm steps slow motion at 30 Hz, stops the frame callback whenever the sky isn't visible, and freezes it under Reduce Motion. We have not published battery measurements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The sky is open source under Apache 2.0: &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;github.com/at6pm/6pm&lt;/a&gt; (start at &lt;code&gt;apps/mobile/src/ui/sky/sky.tsx&lt;/code&gt;). Watch it shift through the day in 6pm: on iPhone (&lt;a href="https://apps.apple.com/app/id6504689878" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;) and Android (beta) · &lt;a href="https://at6pm.app" rel="noopener noreferrer"&gt;at6pm.app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>graphics</category>
      <category>animation</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Why we open-sourced our money app</title>
      <dc:creator>Odd Eggs Studio</dc:creator>
      <pubDate>Sun, 01 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/oddeggs/why-we-open-sourced-our-money-app-26pm</link>
      <guid>https://dev.to/oddeggs/why-we-open-sourced-our-money-app-26pm</guid>
      <description>&lt;p&gt;A privacy policy is a claim; code is evidence. 6pm, an iPhone expense tracker, is open source under Apache 2.0 so anyone can check what it keeps on the phone (an SQLCipher database whose key never leaves the Keychain), what reaches our server (hashes, sealed receipts, ciphertext), and what goes to third parties.&lt;/p&gt;

&lt;p&gt;This post is a guided tour of the parts of &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;github.com/at6pm/6pm&lt;/a&gt; that back the privacy policy, including the parts that are not private. If you only read one section, read "What does leave the phone".&lt;/p&gt;

&lt;h2&gt;
  
  
  The argument for opening it
&lt;/h2&gt;

&lt;p&gt;Money apps ask for a lot of trust. Most ask you to trust a policy page. Ours makes specific promises: no bank login, no account for the core app, records encrypted on the phone, a server that cannot read your backup. Those are statements about code. Opening the code turns them into things you can check.&lt;/p&gt;

&lt;p&gt;The website says it plainly: "The app, the money maths and the privacy promises are all open source." The repository holds the iPhone app (&lt;code&gt;apps/mobile&lt;/code&gt;), the API (&lt;code&gt;apps/api&lt;/code&gt;, Hono on Cloudflare Workers with D1 and R2), the website (&lt;code&gt;apps/web&lt;/code&gt;), the money rules (&lt;code&gt;packages/domain&lt;/code&gt;), the looks (&lt;code&gt;packages/theme&lt;/code&gt;) and the English and Vietnamese copy (&lt;code&gt;packages/i18n&lt;/code&gt;). The licence file is the Apache License, Version 2.0.&lt;/p&gt;

&lt;p&gt;Open source also helps with a duller problem: correctness. The daily number, the pay cycle, the close, the VietQR and receipt parsers all live in &lt;code&gt;packages/domain&lt;/code&gt;, a pure TypeScript package with unit tests. If our maths is wrong, you can show us the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays on the phone
&lt;/h2&gt;

&lt;h3&gt;
  
  
  An encrypted database with a key that never leaves
&lt;/h3&gt;

&lt;p&gt;The app's store is expo-sqlite with SQLCipher turned on in &lt;code&gt;app.config.ts&lt;/code&gt; (&lt;code&gt;['expo-sqlite', { useSQLCipher: true }]&lt;/code&gt;). The key is made on first launch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/db/database-key.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OPTIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SecureStoreOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;keychainAccessible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ensureDatabaseKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;databaseName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;OPTIONS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;clearUnreadableDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;databaseName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getRandomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;OPTIONS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and applied as the first statement on the connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/db/database.ts&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`PRAGMA key = "x'&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;THIS_DEVICE_ONLY&lt;/code&gt; has a consequence the code is honest about. An iCloud or Quick Start restore brings the database file to a new phone without its key, so it can never be opened there. When a key has to be made and a database file already exists, the file is moved aside to a dated name rather than deleted, and the user's days come back through 6pm's own encrypted backup. A Keychain read that fails, as opposed to finding nothing, throws, so a locked Keychain never costs anyone their data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extensions never open the database
&lt;/h3&gt;

&lt;p&gt;iOS kills a suspended extension that holds a file lock in a shared container, and SQLite in WAL mode holds one. So the share extension, widgets and App Intents never open the store. Each capture is written as one JSON file into the App Group's &lt;code&gt;inbox/&lt;/code&gt; folder, which the app imports and deletes. Widgets read files the app writes to &lt;code&gt;surfaces/&lt;/code&gt;. This is in &lt;code&gt;docs/tech-decisions.md&lt;/code&gt; under "Extensions and the store".&lt;/p&gt;

&lt;h3&gt;
  
  
  The maths runs locally
&lt;/h3&gt;

&lt;p&gt;Ask 6pm ("Can I afford $350 on Saturday?") is the clearest example. The phone computes every number from its own records; a model on the server only turns the question into an intent and an amount, and words the answer. Your balances are never sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What our server sees
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A hashed device token, not an account
&lt;/h3&gt;

&lt;p&gt;The core app has no account. On first contact the phone gets a random token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/api/src/device-auth.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hashDeviceToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="cm"&gt;/** The database only ever sees this. */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;D1 stores the hash, the app language and first and last seen times. &lt;code&gt;last_seen_at&lt;/code&gt; is rewritten at most once an hour, because it exists for rough daily counts. Rate limiting keys on the IP address through Cloudflare's rate-limit binding; the comment, and the policy, say the address is a counter key and is never stored.&lt;/p&gt;

&lt;h3&gt;
  
  
  Receipt email: read in memory, sealed until fetched
&lt;/h3&gt;

&lt;p&gt;Each phone gets an address like &lt;code&gt;k7m2qx@at6pm.app&lt;/code&gt;. Cloudflare Email Routing's catch-all hands mail to the Worker. &lt;code&gt;apps/api/src/receipt-email/receive.ts&lt;/code&gt; describes the contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mail to an unknown handle, or over 1 MB, is refused.&lt;/li&gt;
&lt;li&gt;An email that prints no amount is dropped unread and never sent to a model.&lt;/li&gt;
&lt;li&gt;Otherwise it is read in memory, by an Anthropic model when one answers and by rules when it does not. Only what was read (amount, currency, merchant, category, time, sender, memo) is kept. The email itself is never stored or logged.&lt;/li&gt;
&lt;li&gt;Anything that is not a receipt is dropped without a bounce, so a forwarding filter is not disabled by one promo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is kept is sealed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/api/src/receipt-email/seal.ts (doc comment)&lt;/span&gt;
&lt;span class="c1"&gt;// Receipts wait in D1 sealed with AES-256-GCM. The key is derived with HKDF-SHA-256 from the&lt;/span&gt;
&lt;span class="c1"&gt;// EMAIL_SEAL_KEY Worker secret, with the device's token hash as the context, so a copy of the&lt;/span&gt;
&lt;span class="c1"&gt;// database alone opens nothing and one device's key opens no other device's receipts. The&lt;/span&gt;
&lt;span class="c1"&gt;// capture's id is the associated data: a sealed row cannot be passed off under another id.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the phone has saved its receipts it acknowledges them and the rows are deleted (&lt;code&gt;DELETE FROM email_captures WHERE device_hash = ? AND id IN (…)&lt;/code&gt;). Anything unfetched for 30 days is deleted unread.&lt;/p&gt;

&lt;p&gt;To be precise about the threat model: the Worker holds the secret and reads the email in memory, so this protects against a leaked database, not against the server operator. We say so because it is true.&lt;/p&gt;

&lt;h3&gt;
  
  
  Voice: a token, not the audio
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/api/src/routes/voice-token.ts (doc comment)&lt;/span&gt;
&lt;span class="c1"&gt;// Mints a short-lived key to the realtime transcriber for one spend said aloud. The phone then&lt;/span&gt;
&lt;span class="c1"&gt;// streams its microphone to the vendor directly: no audio and no words pass through this server.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server records that a transcription happened, against the device hash, for cost. It never sees the sound or the words.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backup: ciphertext under a name only the key can produce
&lt;/h3&gt;

&lt;p&gt;Backup (a 6pm Max feature, off by default) seals a snapshot on the phone with AES-256-GCM using a 32-byte key kept in iCloud Keychain. The server files it under a token derived from that key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/features/backup/backup-seal.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;backupTokenOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BackupCrypto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;utf8Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;6pm.backup.token.v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;toBase64Url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API then stores only the hash of that token as the R2 object key (&lt;code&gt;backups/&amp;lt;hash&amp;gt;&lt;/code&gt;), and the backup route "checks the envelope's shape and size and nothing else: it cannot open it." No device id is involved, so the server cannot link a backup to a phone. If someone loses both the key and the phone, nobody can open the backup, including us.&lt;/p&gt;

&lt;p&gt;One honest caveat from the code itself: the comment on the Keychain store says whether a key written on one iPhone arrives on another "can only be seen on two real devices signed in to one Apple ID". It is the kind of thing open code lets you see, and test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Merchant names without numbers
&lt;/h3&gt;

&lt;p&gt;When a shared bank receipt has a payee the phone cannot name, the phone sends the receipt's lines to &lt;code&gt;/v1/name-merchant&lt;/code&gt; with every number removed. The route turns away a body holding a digit before any model is called, so amounts, account numbers and reference codes cannot reach it even by mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does leave the phone
&lt;/h2&gt;

&lt;p&gt;This is the section most privacy pages skip. In plain terms, from &lt;code&gt;apps/web/src/legal/privacy.en.md&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic&lt;/strong&gt; reads Ask questions (with your category and pot names), the few facts a companion line is about, number-free receipt lines, a receipt photo the phone could not read with confidence, forwarded receipt emails, and category names for icon suggestions. The policy states Anthropic does not train on them and deletes them within 30 days, with stated exceptions. We keep token counts in a cost ledger, not the content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deepgram or ElevenLabs&lt;/strong&gt; hears your voice while you hold the microphone. Free users get 10 cloud transcriptions a day; after that, offline, or in on-device mode, Apple's on-device speech is used and nothing is sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RevenueCat&lt;/strong&gt; knows what you bought, under an anonymous app user ID, plus the device details purchase SDKs usually receive, including IP address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentry&lt;/strong&gt; gets crash reports with &lt;code&gt;sendDefaultPii: false&lt;/code&gt; and no money, names or notes attached. In on-device mode the reporter is closed, including the native one, so a crash is not sent at the next launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt; runs the site and API and may keep ordinary request logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expo&lt;/strong&gt; answers update checks, which carry the app version, platform and IP address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple&lt;/strong&gt; handles payments, iCloud Keychain and Sign in with Apple, which is used only for Budget together (shared pots for two).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  On-device mode
&lt;/h3&gt;

&lt;p&gt;Settings → AI can keep 6pm's AI on the iPhone: questions are read, companion lines written and receipts read with a model downloaded once from Hugging Face. In that mode receipt email is off (it needs the server), crash reports are off, and voice uses Apple's on-device recogniser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What open source does not prove
&lt;/h2&gt;

&lt;p&gt;It proves what the code in the repository does. It does not, by itself, prove that the binary in the App Store was built from that commit, or what a vendor does once data reaches it. We would rather say that than overstate it. What it does give you is the ability to read every route the app calls, every field the server stores, and every place a model is involved, and to hold us to the policy line by line.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What licence is 6pm under?
&lt;/h3&gt;

&lt;p&gt;Apache License 2.0, as in the &lt;code&gt;LICENSE&lt;/code&gt; file at the root of the repository.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can 6pm read my spending?
&lt;/h3&gt;

&lt;p&gt;The records live in an SQLCipher database on your iPhone, with a key made on the phone and kept in its Keychain. We never receive the key or the database. We never store your transactions; they live on your iPhone, encrypted. No bank login.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does open source mean I can self-host the API?
&lt;/h3&gt;

&lt;p&gt;The API is in the repo (Hono on Cloudflare Workers, D1, R2) and server secrets are listed by name in &lt;code&gt;.env.example&lt;/code&gt;. The shipping app talks to at6pm.app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I contribute?
&lt;/h3&gt;

&lt;p&gt;Read &lt;code&gt;CLAUDE.md&lt;/code&gt; first; it is the working contract for anyone, human or agent, changing the code. Questions and bug reports go to &lt;a href="mailto:hello@at6pm.app"&gt;hello@at6pm.app&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Read the code at &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;github.com/at6pm/6pm&lt;/a&gt;. The iPhone app is in public beta on TestFlight: &lt;a href="https://testflight.apple.com/join/a8UrvT5P" rel="noopener noreferrer"&gt;testflight.apple.com/join/a8UrvT5P&lt;/a&gt;, and on the App Store from 6 November 2026, 18:00 ICT. More at &lt;a href="https://at6pm.app" rel="noopener noreferrer"&gt;at6pm.app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>privacy</category>
      <category>security</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>One camera for QR codes and receipts with Apple Vision</title>
      <dc:creator>Odd Eggs Studio</dc:creator>
      <pubDate>Wed, 28 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/oddeggs/one-camera-for-qr-codes-and-receipts-with-apple-vision-76f</link>
      <guid>https://dev.to/oddeggs/one-camera-for-qr-codes-and-receipts-with-apple-vision-76f</guid>
      <description>&lt;p&gt;6pm's Scan runs one &lt;code&gt;AVCaptureSession&lt;/code&gt; with three outputs: the system metadata reader for QR codes, a video feed that Vision searches for paper ten times a second, and a photo output. A receipt held still for 0.9 s is taken, squared up with &lt;code&gt;CIPerspectiveCorrection&lt;/code&gt;, and read on the phone. Only an unsure reading sends the photo, once.&lt;/p&gt;

&lt;p&gt;The code is in &lt;code&gt;apps/mobile/modules/scan-camera&lt;/code&gt; (a local Expo module, Swift and Kotlin) and &lt;code&gt;packages/domain/src/capture&lt;/code&gt; (the receipt parser, TypeScript). The repo is Apache 2.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  The product constraint
&lt;/h2&gt;

&lt;p&gt;Scan has one job per moment: at a counter you want the QR read instantly; at home with a crumpled receipt you want it found, cut out and read. Two modes with a switch would be simpler to build. It would also mean a choice at the till. So there is one camera, and it looks for both at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  One session, three outputs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/modules/scan-camera/ios/ScanCameraSession.swift (trimmed)&lt;/span&gt;
&lt;span class="c1"&gt;/// One capture session that finds QR codes (the system's metadata reader, as fast as the&lt;/span&gt;
&lt;span class="c1"&gt;/// Camera app's) and the receipt in view (Vision's document segmentation, ten times a&lt;/span&gt;
&lt;span class="c1"&gt;/// second), and takes the full-resolution photo a receipt is read from.&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;ScanCameraSession&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NSObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;@unchecked&lt;/span&gt; &lt;span class="kt"&gt;Sendable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVCaptureSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;photoOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVCapturePhotoOutput&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;videoOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVCaptureVideoDataOutput&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;metadataOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVCaptureMetadataOutput&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;receiptInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CFTimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;missesReported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;minimumArea&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.06&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;QR codes&lt;/strong&gt; come from &lt;code&gt;AVCaptureMetadataOutput&lt;/code&gt; with &lt;code&gt;metadataObjectTypes = [.qr]&lt;/code&gt;, delivered on the main queue. It is the same reader the Camera app uses, and it is faster than running a Vision barcode request per frame. The same code is ignored for a second after it fires, so holding the phone over a code does not spam the parser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Receipts&lt;/strong&gt; come from the video output, &lt;code&gt;kCVPixelFormatType_420YpCbCr8BiPlanarFullRange&lt;/code&gt;, with &lt;code&gt;alwaysDiscardsLateVideoFrames = true&lt;/code&gt;. The delegate throttles itself to one look every 100 ms: "often enough for the outline to follow the paper, rare enough to leave the phone cool", as the comment puts it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The photo&lt;/strong&gt; comes from &lt;code&gt;AVCapturePhotoOutput&lt;/code&gt; at &lt;code&gt;.balanced&lt;/code&gt; quality, taken only when a receipt is captured.&lt;/p&gt;

&lt;p&gt;Session work runs on one serial queue, frames on another, and the one flag both read (whether to look for receipts at all) sits behind an &lt;code&gt;NSLock&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Picking the lens
&lt;/h3&gt;

&lt;p&gt;A receipt is held close. The session asks for the virtual multi-camera device first, so iOS can switch to the macro-capable lens as the Camera app does, and starts it at its 1x lens rather than its widest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;types&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;AVCaptureDevice&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;DeviceType&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;back&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builtInTripleCamera&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builtInDualWideCamera&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builtInWideAngleCamera&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;builtInWideAngleCamera&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;wide&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;virtualDeviceSwitchOverVideoZoomFactors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;videoZoomFactor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;truncating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wide&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Finding the paper
&lt;/h2&gt;

&lt;p&gt;Vision has two tools for this, and we use both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/modules/scan-camera/ios/ReceiptPhoto.swift (trimmed)&lt;/span&gt;
&lt;span class="c1"&gt;/// Its document segmentation first, and where that finds nothing (it needs the Neural&lt;/span&gt;
&lt;span class="c1"&gt;/// Engine, and misses paper on a busy table) the plainest rectangle in view.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;VNImageRequestHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;minimumArea&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Corners&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;segmentation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;VNDetectDocumentSegmentationRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rectangles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;VNDetectRectanglesRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="c1"&gt;// A receipt is long and narrow, and photographed at an angle.&lt;/span&gt;
  &lt;span class="n"&gt;rectangles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minimumAspectRatio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;
  &lt;span class="n"&gt;rectangles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maximumAspectRatio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="n"&gt;rectangles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quadratureTolerance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
  &lt;span class="n"&gt;rectangles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minimumSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;
  &lt;span class="n"&gt;rectangles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minimumConfidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;
  &lt;span class="n"&gt;rectangles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maximumObservations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;segmentation&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;segmentation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;minimumConfidence&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;corners&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Corners&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;corners&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;minimumArea&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;corners&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;guard&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;rectangles&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rectangles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;corners&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Corners&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;corners&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;area&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;minimumArea&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;corners&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;VNDetectDocumentSegmentationRequest&lt;/code&gt; is the better detector for paper, but it misses on cluttered tables. The rectangle detector is tuned for receipts: aspect ratios down to 0.15 (long and thin) and 30 degrees of tolerance for a phone held at an angle. Anything covering less than a few percent of the frame is a label or a card, not the receipt; the shoelace formula on the quad gives the area.&lt;/p&gt;

&lt;p&gt;Vision's origin is bottom left; React Native's is top left. &lt;code&gt;Corners&lt;/code&gt; flips once, at the boundary, and everything downstream uses &lt;code&gt;[x0, y0, …, x3, y3]&lt;/code&gt; from the top left, clockwise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Losing it gracefully
&lt;/h3&gt;

&lt;p&gt;Detectors flicker. A frame where the outline vanishes is usually the detector, not the hand. The session keeps reporting "gone" for four looks after a loss, then goes quiet, and the TypeScript side lets two stray sightings pass before it believes the receipt moved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/features/scan/receipt-outline.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STEADY_SHARE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a corner may wander 8% of the shortest side&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STEADY_POINTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// …and never less than 10 points&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_STRAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// one or two misses are the detector, not the hand&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STEADY_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// held still this long: take the photo&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MIN_SHARE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// smaller than this on screen: outlined, not taken&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tolerance scales with the receipt's shortest side on screen, so a receipt held close may shake as much as one held far. Stillness is measured from where the receipt settled, not from the last sighting, so a slow drift still resets the timer. After 0.9 s still, Scan takes the photo itself. There is also a shutter.&lt;/p&gt;

&lt;p&gt;Mapping corners onto the preview has one trap: the preview is aspect-fill, so the frame is scaled to cover the view and cropped. &lt;code&gt;quadOnView&lt;/code&gt; applies the same scale and centring offset, so the drawn outline sits on the paper rather than near it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cutting it out and reading it
&lt;/h2&gt;

&lt;p&gt;The full-resolution photo goes through four steps in &lt;code&gt;ReceiptPhoto.read&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upright and bounded.&lt;/strong&gt; &lt;code&gt;CGImageSourceCreateThumbnailAtIndex&lt;/code&gt; with the transform applied, at most 3,000 px on the long side, which keeps the small print on a long receipt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the paper again&lt;/strong&gt; in the photo. If that fails, use where the live view last saw it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Square it up&lt;/strong&gt; with Core Image:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CIImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;cgImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;applyingFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s"&gt;"CIPerspectiveCorrection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nv"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"inputTopLeft"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;corners&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;topLeft&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s"&gt;"inputTopRight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;corners&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;topRight&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s"&gt;"inputBottomRight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;corners&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bottomRight&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s"&gt;"inputBottomLeft"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;corners&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bottomLeft&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read it&lt;/strong&gt; at 2,000 px with &lt;code&gt;VNRecognizeTextRequest&lt;/code&gt; at &lt;code&gt;.accurate&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;vietnamese&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;supported&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lowercased&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"vi"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recognitionLanguages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;vietnamese&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"en-US"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usesLanguageCorrection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recognitionLanguages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"en-US"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usesLanguageCorrection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second branch matters for Vietnamese receipts on a phone whose Vision lacks Vietnamese: with English language correction on, Vietnamese words get "corrected" into English ones. Off, the raw glyphs survive, and the parser matches labels with accents folded away anyway.&lt;/p&gt;

&lt;p&gt;A picture chosen from Photos goes through the same path, after a &lt;code&gt;VNDetectBarcodesRequest&lt;/code&gt; decides whether it is a QR screenshot instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parser: lines in, a total out
&lt;/h2&gt;

&lt;p&gt;Vision returns lines with boxes and confidence. &lt;code&gt;packages/domain/src/capture/shop-receipt.ts&lt;/code&gt; turns them into a &lt;code&gt;ShopReceipt&lt;/code&gt;: total, currency, merchant, time, plus two flags, &lt;code&gt;totalSure&lt;/code&gt; and &lt;code&gt;currencySure&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Totals are found by label, ranked. The labels are folded (no accents, lower case) in Vietnamese, English and Indonesian:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/domain/src/capture/shop-labels.ts (excerpt)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOTAL_RANK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;final&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;running&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LABELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;final&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tong con lai, con phai thu, con lai, khach phai tra, khach can tra, can thanh toan, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;phai thanh toan, tien phai tra, tong thanh toan, total amount, grand total, amount due, …&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tong cong, tong tien, tong, thanh toan, total, jumlah&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;running&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;thanh tien&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;totalSure&lt;/code&gt; means a total label was read and nothing on the receipt argues with it: no second total of the same rank with a different amount, no card charge or subtotal that disagrees. &lt;code&gt;currencySure&lt;/code&gt; means the receipt printed its currency or is plainly Vietnamese.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the phone is not sure
&lt;/h2&gt;

&lt;p&gt;The card that shows the reading decides whether to ask for help:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/domain/src/native/share-card.ts&lt;/span&gt;
&lt;span class="nx"&gt;needsCheck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shop&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalSure&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currencySure&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only then, and only if the user has not chosen to keep AI on the phone, the app sends one JPEG (long side 1,600 px, at most 1.5 MB, quality stepped down from 0.7 until it fits) to &lt;code&gt;POST /api/v1/receipts/read&lt;/code&gt;. The server asks an Anthropic vision model for the total as printed, the currency, the shop and the time; code, not the model, converts the printed total into an amount. The server does not store or log the photo. Offline, or when the server has no answer, the phone keeps its own reading.&lt;/p&gt;

&lt;p&gt;When the closer look returns, &lt;code&gt;lookedReceiptCard&lt;/code&gt; merges it over the phone's reading. Fields the phone read surely stay as they were; the server fills what the phone was unsure of.&lt;/p&gt;

&lt;p&gt;The share extension (sharing a bank's "transfer successful" screenshot into 6pm) reads with the same parser and the same rules; the Scan path was written to read a receipt "exactly as one shared to 6pm".&lt;/p&gt;

&lt;h2&gt;
  
  
  The Android port
&lt;/h2&gt;

&lt;p&gt;Android is not released yet, but the camera module has a Kotlin side in &lt;code&gt;modules/scan-camera/android&lt;/code&gt;, kept to "the same steps and limits as the iPhone's reader".&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Camera:&lt;/strong&gt; CameraX, with an &lt;code&gt;ImageAnalysis&lt;/code&gt; use case on &lt;code&gt;STRATEGY_KEEP_ONLY_LATEST&lt;/code&gt; and YUV_420_888 frames.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QR:&lt;/strong&gt; ML Kit &lt;code&gt;barcode-scanning&lt;/code&gt; restricted to &lt;code&gt;FORMAT_QR_CODE&lt;/code&gt;, run on every analysed frame, with the same one-second repeat guard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text:&lt;/strong&gt; ML Kit &lt;code&gt;text-recognition&lt;/code&gt; with the Latin recogniser, whose languages include Vietnamese. Its boxes are converted to Vision's bottom-left convention so the TypeScript parser does not care which phone it runs on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paper:&lt;/strong&gt; this was the real work. Android has no rectangle detector of its own, and ML Kit's document scanner is a full-screen flow, not a frame-by-frame detector. So &lt;code&gt;PaperFinder.kt&lt;/code&gt; is a plain one, about 400 lines:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/modules/scan-camera/android/.../PaperFinder.kt (doc comment)&lt;/span&gt;
&lt;span class="c1"&gt;// The paper is the largest bright patch that stands out from what is around it and is shaped&lt;/span&gt;
&lt;span class="c1"&gt;// like a four-cornered sheet: the picture cut at a few brightness levels, the largest bright&lt;/span&gt;
&lt;span class="c1"&gt;// regions of each cut, their convex hulls and the largest quadrilateral that fits in each,&lt;/span&gt;
&lt;span class="c1"&gt;// then checks that the region really is that quadrilateral and stands out from its surroundings.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works on a grey grid: the Y plane sampled down to 160 px on the long side for the live view (four samples per cell), 400 px for the photo. One threshold rarely fits, since paper on a pale table needs a higher cut than paper on a dark one, so it tries Otsu's threshold and four levels between it and the 98th percentile, and keeps the largest sheet. Checks reject blobs (solidity below 0.8), shapes with more than four corners, regions filling over 92% of the frame (that is the table), corners more than 40 degrees off square, and paper less than 35 grey levels brighter than its surroundings.&lt;/p&gt;

&lt;p&gt;Straightening uses &lt;code&gt;Matrix.setPolyToPoly&lt;/code&gt; with four points and a filtered &lt;code&gt;Canvas.drawBitmap&lt;/code&gt;, the Android equivalent of &lt;code&gt;CIPerspectiveCorrection&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use the platform's fast path for the easy thing.&lt;/strong&gt; The metadata reader handles QR better than per-frame Vision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two detectors beat one tuned detector.&lt;/strong&gt; Segmentation for clean cases, rectangles for cluttered ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make "unsure" a first-class output.&lt;/strong&gt; The parser's &lt;code&gt;totalSure&lt;/code&gt; and &lt;code&gt;currencySure&lt;/code&gt; flags are what keep most receipts on the phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalise coordinates once, at the boundary.&lt;/strong&gt; Both platforms hand TypeScript the same corner order and origin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does every receipt photo go to a server?
&lt;/h3&gt;

&lt;p&gt;No. Every receipt is read on the phone first. A photo is sent once only when the phone is not sure of the total or the currency, and never in on-device AI mode.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not use VisionKit's document camera?
&lt;/h3&gt;

&lt;p&gt;Scan needs QR codes and receipts in one live view, with its own outline and auto-capture. The building blocks (AVFoundation plus Vision requests) allow that; a full-screen system flow does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the Android version released?
&lt;/h3&gt;

&lt;p&gt;No. 6pm launches on iPhone. The Android camera module is in the repo and builds in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;github.com/at6pm/6pm&lt;/a&gt;: &lt;code&gt;apps/mobile/modules/scan-camera&lt;/code&gt; and &lt;code&gt;packages/domain/src/capture&lt;/code&gt;. The iPhone app is in public beta on TestFlight: &lt;a href="https://testflight.apple.com/join/a8UrvT5P" rel="noopener noreferrer"&gt;testflight.apple.com/join/a8UrvT5P&lt;/a&gt;, and on the App Store from 6 November 2026, 18:00 ICT. More at &lt;a href="https://at6pm.app" rel="noopener noreferrer"&gt;at6pm.app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>android</category>
      <category>computervision</category>
    </item>
    <item>
      <title>11.11 in Vietnam: 3 QR payment scams, and what they changed in our QR-reading app</title>
      <dc:creator>Odd Eggs Studio</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/oddeggs/1111-in-vietnam-3-qr-payment-scams-and-what-they-changed-in-our-qr-reading-app-ek</link>
      <guid>https://dev.to/oddeggs/1111-in-vietnam-3-qr-payment-scams-and-what-they-changed-in-our-qr-reading-app-ek</guid>
      <description>&lt;p&gt;&lt;em&gt;We're a small studio in Saigon building 6pm, an iPhone expense tracker that reads the VietQR code at the counter to log a spend. Because the app touches QR payments, we spent a while reading what Vietnam's police and banks say about QR scams, and the 11.11 sales are when these peak. This is the user-side write-up; the notes for builders are at the end. Originally published on &lt;a href="https://at6pm.app/blog/qr-code-scams-vietnam-sales-season" rel="noopener noreferrer"&gt;at6pm.app&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In Vietnam almost everything is paid by scanning a QR code, and the 11.11 sales are when people scan fastest. Three scams come up again and again: a fake QR sticker over a shop's real code, a "wrong transfer, please scan this to send it back" message, and a fake courier asking for an extra fee by QR. Police and banks have warned about all three. One habit protects you from each of them: after you scan, read the payee's name in your banking app before you confirm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sale season is QR scam season
&lt;/h2&gt;

&lt;p&gt;On 11.11 you scan more than usual: delivery fees, deposits, the coffee on the way to pick up a parcel. You are also in a hurry, because a flash sale lasts minutes. That is exactly what a scammer needs: lots of transactions and little time to look.&lt;/p&gt;

&lt;p&gt;Vietnamese police call this family of scams &lt;strong&gt;Quishing&lt;/strong&gt;, from "QR code" and "phishing". &lt;a href="https://vneconomy.vn/cong-an-tp-ha-noi-canh-bao-ve-hinh-thuc-lua-dao-quishing-dang-bung-phat.htm" rel="noopener noreferrer"&gt;Hanoi police have warned&lt;/a&gt; that it is spreading, and their &lt;a href="https://doanhnhan.baophapluat.vn/cong-an-ha-noi-canh-bao-nguoi-dan-canh-giac-truoc-25-kich-ban-lua-dao-tren-khong-gian-mang-nam-2026-7cc6172e.html" rel="noopener noreferrer"&gt;list of 25 scam scripts for 2026&lt;/a&gt; names malicious QR codes and fake transfer receipts (both sources in Vietnamese).&lt;/p&gt;

&lt;h2&gt;
  
  
  Scam 1: a fake QR code stuck over the shop's own
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffvqvqpmvnn9rsdckiky4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffvqvqpmvnn9rsdckiky4.jpg" alt="A brand-new QR code stuck over a shop's code, with a peeling corner, and the advice to ask the seller before scanning" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The scammer prints a new QR code that points to their own account and sticks it over the payment code at a restaurant, a café or a bus station. You scan, and the money lands in a stranger's account. The shop gets nothing, and you find out when the owner asks whether you've paid yet.&lt;/p&gt;

&lt;p&gt;Hanoi police describe exactly this: codes pasted over or swapped at restaurants and bus stations to take the money when people pay. The Vietnam Banks Association &lt;a href="https://vnba.org.vn/vi/ngan-hang-tiep-tuc-canh-bao-nhung-thu-doan-lua-dao-cong-nghe-moi-18352.htm" rel="noopener noreferrer"&gt;summarised warnings from Vietcombank, ABBank, KienlongBank and VRB&lt;/a&gt; that list fake QR codes stuck over real ones in restaurants, stations and payment points.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signs:&lt;/strong&gt; a code stuck on top of another, peeling edges, a brand-new sticker on an old counter, or two codes side by side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do:&lt;/strong&gt; ask the seller "is this your code?" before you scan. After scanning, the payee name in your banking app should match the shop or its owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scam 2: "I sent it by mistake, scan this to send it back"
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1yxactqlxdqmrfd6sdlj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1yxactqlxdqmrfd6sdlj.jpg" alt="Two messages: " width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Money you weren't expecting arrives in your account. Minutes later, a message or a call: "I transferred it to you by mistake, please send it back", with a QR code or a different account number. Sometimes the caller claims to be from the bank, offering to "help you get the money back".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://thitruongtaichinhtiente.vn/canh-giac-thu-doan-lua-dao-va-quy-trinh-xu-ly-an-toan-khi-chuyen-nham-tien-qua-tai-khoan-77772.html" rel="noopener noreferrer"&gt;Phú Thọ provincial police describe&lt;/a&gt; two versions. In the first, scammers send a small "bait" amount, then pose as the mistaken sender or a debt collector and pressure you to transfer money to an account they choose. In the second, they pose as bank staff and ask you to scan a QR code in your banking app "to verify". According to the warning, once the victim scans, the code carries out a transfer of the whole balance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The safe way to handle it&lt;/strong&gt;, per the same police advice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Money arrives that isn't yours: don't withdraw it or send it anywhere. Tell your bank, and return it only through the bank's official channel or with the authorities as witness.&lt;/li&gt;
&lt;li&gt;You sent money by mistake: keep the transaction screenshots and ask your bank to trace it. Never pay a "fee" to a stranger online who promises to recover it.&lt;/li&gt;
&lt;li&gt;Don't scan QR codes or tap links that strangers send.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Scam 3: the fake courier, "your order still has a fee"
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsh570egb6wgasdvkwzv9.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsh570egb6wgasdvkwzv9.jpg" alt="A parcel and a QR code, with the advice to open the shopping app and check whether the order is already paid" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sale season means the most parcels, so it is also peak season for fake couriers. The usual script: a delivery call at a time you're out, then a message asking you to transfer first, or "your order still has a fee, scan this code".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nhandan.vn/ocop/bo-cong-an-canh-bao-hien-tuong-gia-danh-shipper-de-lua-dao-chiem-doat-tai-san-post871920.html" rel="noopener noreferrer"&gt;Vietnam's Ministry of Public Security has warned&lt;/a&gt; that fake couriers call when you're likely not home, send transfer details, deliver fake or low-quality goods and refuse inspection. Its advice includes one short line: &lt;strong&gt;don't scan QR codes from unclear sources.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A newer twist: in August 2026, &lt;a href="https://lsvn.vn/canh-bao-thu-doan-gia-danh-shipper-don-vi-giao-hang-lua-dao-chiem-doat-tai-san-a177201.html" rel="noopener noreferrer"&gt;Hưng Yên provincial police warned&lt;/a&gt; of callers posing as couriers who say they sent the wrong payment account for an order, then steer the victim to a fake Facebook page of the delivery company to "file a refund complaint". A second person, posing as customer care, sends a "refund confirmation code". When the victim enters it in their banking app, it is actually a transfer of more than 28 million đồng.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open the shopping app and check whether the order is paid and what the delivery fee is. A real courier won't need any amount beyond what the order says.&lt;/li&gt;
&lt;li&gt;Pay only for orders you placed, preferably through the marketplace or directly to the seller.&lt;/li&gt;
&lt;li&gt;If in doubt, call the delivery company's official hotline, not a number a stranger gives you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 3-second rule: after you scan, read the payee's name
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa2v31aseciqupmecib09.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa2v31aseciqupmecib09.jpg" alt="The 3-second rule: read the payee's name before you tap send, don't scan codes strangers send, never read an OTP out to anyone" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All three scams share one weakness: the money has to go to an account that isn't the one you meant to pay. And before you confirm, your banking app shows the payee's name on the transfer screen.&lt;/p&gt;

&lt;p&gt;So before you tap send, take 3 seconds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the payee's name.&lt;/strong&gt; It doesn't match the shop, the seller or the friend you're paying? Stop. Don't send.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't scan codes strangers send you&lt;/strong&gt; by text, Zalo or Messenger, however urgent the story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never read an OTP out to anyone&lt;/strong&gt;, including people who say they are the bank or the police. Hanoi police repeat this in their 2026 warning: never share passwords or OTPs, and don't transfer money before you've verified who receives it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Send this to your parents. Older family members may scan less, but they are also less likely to doubt a "wrong transfer" message.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 6pm does, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;6pm is an expense tracker. When you scan a VietQR code with 6pm, it reads the account number, amount and message in the code, logs the spend, then opens the banking app you choose. &lt;strong&gt;The payment still happens in your banking app&lt;/strong&gt;, where you see the payee's name and confirm as usual.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;6pm &lt;strong&gt;never moves money&lt;/strong&gt;. It only counts.&lt;/li&gt;
&lt;li&gt;6pm &lt;strong&gt;never asks for an OTP&lt;/strong&gt;, a password or your bank login.&lt;/li&gt;
&lt;li&gt;We &lt;strong&gt;never store your transactions&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;6pm &lt;strong&gt;can't detect fake codes&lt;/strong&gt;, and we don't claim it can. The 3-second rule is still yours to follow, in any app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where 6pm helps: every scan lands in your log, and at 6pm you go through each spend when you close the day. A strange amount, two payments for one order, or an odd "delivery fee" is easier to spot when they sit side by side on one receipt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes for anyone building on QR payments
&lt;/h2&gt;

&lt;p&gt;Reading these warnings shaped a few rules in our own app, and they apply to anything that scans a payment code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never complete the payment yourself.&lt;/strong&gt; We read the code, log the spend, then hand off to the user's banking app, so the bank's confirm screen with the payee's name is always the last step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never ask for an OTP, a password or a bank login.&lt;/strong&gt; The "scan to verify" call in scam 2 and the "refund confirmation code" in scam 3 both get the victim to act inside their own banking app; an app that only counts has no reason to ask for any of that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't promise what you can't check.&lt;/strong&gt; A scanner can't tell a sticker over the real code from the real code, so we don't say ours can. Telling people that plainly is better than a "verified" badge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The parser side (TLV, CRC-16, NAPAS 247 and handing a filled-in payment to 28 bank apps) is in an earlier post: &lt;a href="https://dev.to/oddeggs/reading-vietqr-on-the-phone-tlv-napas-and-28-bank-apps-4ghl"&gt;Reading VietQR on the phone&lt;/a&gt;. The app is open source: &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;github.com/at6pm/6pm&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a QR code scam (Quishing)?
&lt;/h3&gt;

&lt;p&gt;A scam that uses a fake or malicious QR code: stuck over a real payment code, sent by message or email, or printed on fake goods. You scan, and the money goes to the scammer, or you land on a fake site that collects your login details.&lt;/p&gt;

&lt;h3&gt;
  
  
  Someone sent money to my account by mistake. What should I do?
&lt;/h3&gt;

&lt;p&gt;Don't withdraw or forward it, and don't scan any code they send. Tell your bank and return the money only through the bank's official channel or with the authorities as witness, as Phú Thọ police advise.&lt;/p&gt;

&lt;h3&gt;
  
  
  A courier says my order has an extra fee. Should I scan their code?
&lt;/h3&gt;

&lt;p&gt;Not straight away. Check in the shopping app whether the order is paid and what the fee is. If you still have doubts, call the delivery company's official hotline or contact the seller.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is scanning with 6pm safer?
&lt;/h3&gt;

&lt;p&gt;6pm doesn't move money and doesn't recognise fake codes. It logs the spend and opens your banking app, where you still read the payee's name and confirm yourself. The 3-second rule applies exactly as it does in your banking app.&lt;/p&gt;

&lt;h3&gt;
  
  
  I've been scammed. What now?
&lt;/h3&gt;

&lt;p&gt;Call your bank's hotline straight away and ask for the transaction to be traced, keep screenshots, call recordings and statements, and report it to the nearest police station.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try 6pm
&lt;/h2&gt;

&lt;p&gt;6pm arrives on the App Store at 18:00 on Friday 6 November 2026 (Vietnam time), in time for 11.11. Want it sooner? Join the public TestFlight beta: &lt;a href="https://testflight.apple.com/join/a8UrvT5P" rel="noopener noreferrer"&gt;testflight.apple.com/join/a8UrvT5P&lt;/a&gt;. More at &lt;a href="https://at6pm.app" rel="noopener noreferrer"&gt;at6pm.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Every spend, caught.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your turn:&lt;/strong&gt; if you've built a payment flow on QR codes anywhere (PromptPay, PayNow, Pix, UPI), what did you do about codes stuck over the real one? We'd like to compare notes in the comments.&lt;/p&gt;

</description>
      <category>security</category>
      <category>fintech</category>
      <category>mobile</category>
      <category>ux</category>
    </item>
    <item>
      <title>Widgets, Controls and Live Activities from an Expo app</title>
      <dc:creator>Odd Eggs Studio</dc:creator>
      <pubDate>Sun, 25 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/oddeggs/widgets-controls-and-live-activities-from-an-expo-app-2i28</link>
      <guid>https://dev.to/oddeggs/widgets-controls-and-live-activities-from-an-expo-app-2i28</guid>
      <description>&lt;p&gt;6pm is an Expo app with three native extensions: a widget extension (Home and Lock Screen widgets, two Control Center controls, one Live Activity), an App Intents extension ("Log in 6pm" for Siri and Apple Pay automations) and a share extension. The extensions never open the database, and Swift never decides a number: TypeScript writes JSON, Swift lays it out.&lt;/p&gt;

&lt;p&gt;Code below is from the &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;6pm repo&lt;/a&gt;. The stack: Expo 58, React Native 0.88 on the New Architecture, and &lt;code&gt;@bacons/apple-targets&lt;/code&gt; 5.0.0 for the native targets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;

&lt;p&gt;Everything native beyond the app itself lives in &lt;code&gt;apps/mobile/targets&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;targets/
  widgets/    WidgetKit: SafeTodayWidget, TodayListWidget, SurfaceActivityWidget, two controls
  intents/    App Intents extension: LogSpendIntent, open-screen intents
  share/      Share extension: reads a bank receipt with Vision
  _shared/    GroupFiles.swift, DomainBridge.swift, ThemeTokens.swift, strings, domain-native.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each target is declared in a few lines of JavaScript. The widget extension:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/targets/widgets/expo-target.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;widget&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;bundleIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.widgets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;deploymentTarget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;17.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;frameworks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WidgetKit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SwiftUI&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ActivityKit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AppIntents&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;com.apple.security.application-groups&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;com.apple.security.application-groups&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;expo prebuild&lt;/code&gt; turns that into an Xcode target. &lt;code&gt;apps/mobile/ios&lt;/code&gt; is generated and git-ignored, so the targets folder and &lt;code&gt;app.config.ts&lt;/code&gt; are the only source of truth.&lt;/p&gt;

&lt;p&gt;The bundle itself is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/targets/widgets/Bundle.swift&lt;/span&gt;
&lt;span class="kd"&gt;@main&lt;/span&gt;
&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;SixpmWidgets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;WidgetBundle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;Widget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;SafeTodayWidget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kt"&gt;TodayListWidget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kt"&gt;SurfaceActivityWidget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;#available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iOS&lt;/span&gt; &lt;span class="mf"&gt;18.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kt"&gt;ScanQRControl&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="kt"&gt;QuickAddControl&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deployment target is iOS 17; controls are iOS 18 only, so they sit behind &lt;code&gt;#available&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule one: extensions never touch the database
&lt;/h2&gt;

&lt;p&gt;The app's source of truth is SQLite with SQLCipher (via expo-sqlite). It would be natural to put that file in the App Group and let the widget read it. We deliberately don't.&lt;/p&gt;

&lt;p&gt;SQLite in WAL mode holds a file lock, and iOS kills a suspended extension that holds a lock on a file in a shared container (the &lt;code&gt;0xdead10cc&lt;/code&gt; termination). So the contract, from &lt;code&gt;docs/tech-decisions.md&lt;/code&gt;, is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each capture an extension makes is one JSON file in &lt;code&gt;&amp;lt;group&amp;gt;/inbox/&lt;/code&gt;. The app imports it through the normal capture path and deletes it.&lt;/li&gt;
&lt;li&gt;The app writes what extensions show to &lt;code&gt;&amp;lt;group&amp;gt;/surfaces/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Files are written whole and renamed into place, readable after first unlock, and excluded from backups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole App Group layer is one small Swift file, shared between the app's local Expo module and the extensions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/targets/_shared/GroupFiles.swift (generated from modules/surfaces/ios)&lt;/span&gt;
&lt;span class="c1"&gt;/// Writes the whole file to a temporary name and renames it into place.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completeFileProtectionUntilFirstUserAuthentication&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
  &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;URLResourceValues&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isExcludedFromBackup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setResourceValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;/// Leaves one capture for the app to import. Extensions only ever write here.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;writeInbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"inbox"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendingPathComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;.json"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The group id is derived from the bundle id, so the dev and store builds never share a container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule two: Swift lays out, TypeScript decides
&lt;/h2&gt;

&lt;p&gt;The daily number ("Safe to spend today") is computed by &lt;code&gt;@6pm/domain&lt;/code&gt;, a tested TypeScript package. We did not want a second copy of that logic in Swift. So the app writes a snapshot, and the widget draws it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;surfaceSnapshot()&lt;/code&gt; in &lt;code&gt;apps/mobile/src/features/surfaces/surface-snapshot.ts&lt;/code&gt; produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Entries:&lt;/strong&gt; one for the rest of this money day, and one starting at the next 04:00 (6pm's money day ends at 04:00, so a late-night spend belongs to yesterday). The second entry means the number turns over at 4 a.m. without the app running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Palettes for every hour:&lt;/strong&gt; the widget's colours follow the app's time-of-day sky, so the app writes 24 sets of colour tokens plus the companion's mood for each hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strings:&lt;/strong&gt; every word, already in the user's language (English or Vietnamese), plus Material Symbols glyphs as private-use characters so Swift needs no icon names.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the Swift side, a timeline provider turns that into WidgetKit entries, one at the top of every hour so the colours follow the sky:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/targets/widgets/SurfaceTimeline.swift (trimmed)&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;timeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Timeline&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;SurfaceEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;loaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SurfaceSnapshot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SurfaceEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setUp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="nv"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[:])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Timeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;never&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;starts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;surfaceDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;starts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addingTimeInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastStretch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 24 h&lt;/span&gt;

  &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;starts&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;dates&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Calendar&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dateInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dates&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addingTimeInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... one SurfaceEntry per date; past `end` the state is .stale ("Open 6pm")&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The snapshot carries a version (&lt;code&gt;v&lt;/code&gt;). If a widget finds a version it does not read, it shows the set-up state instead of decoding garbage. When the app writes a new snapshot it reloads everything it owns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/modules/surfaces/ios/SurfaceFiles.swift&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;reloadWidgets&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;WidgetCenter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reloadAllTimelines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;#available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iOS&lt;/span&gt; &lt;span class="mf"&gt;18.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;ControlCenter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reloadAllControls&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One widget, four families
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SafeTodayWidget&lt;/code&gt; covers the Home Screen (small, medium) and the Lock Screen (a ring and a card):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/targets/widgets/SafeTodayWidget.swift&lt;/span&gt;
&lt;span class="kt"&gt;StaticConfiguration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"SafeToday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SurfaceTimelineProvider&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="kt"&gt;SafeTodayView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;supportedFamilies&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;systemSmall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;systemMedium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accessoryCircular&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accessoryRectangular&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contentMarginsDisabled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;family&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;accessoryCircular&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SafeRing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;accessoryRectangular&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SafeCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;systemMedium&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SafeMedium&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SafeSmall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TodayListWidget&lt;/code&gt; is the large size: what's left, a spent bar and the newest spends.&lt;/p&gt;

&lt;p&gt;The companion (a little sun, by default) appears in the widgets as PNGs the app renders and drops into the group (&lt;code&gt;companion-&amp;lt;mood&amp;gt;-&amp;lt;size&amp;gt;.png&lt;/code&gt;). In serious mode the snapshot says so and the widget leaves it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controls: SF Symbols and universal links
&lt;/h2&gt;

&lt;p&gt;iOS 18 controls live in Control Center, on the Lock Screen's bottom corners and on the Action button. They only take SF Symbols, so where the app draws Material Symbols, the controls wear &lt;code&gt;qrcode.viewfinder&lt;/code&gt; and &lt;code&gt;plus&lt;/code&gt;. Each control opens its screen through a universal link, which the app routes whether it was running or not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/targets/widgets/Controls.swift&lt;/span&gt;
&lt;span class="kd"&gt;@available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iOS&lt;/span&gt; &lt;span class="mf"&gt;18.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;ScanQRControl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ControlWidget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;ControlWidgetConfiguration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;StaticControlConfiguration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ScanQR"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kt"&gt;ControlWidgetButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;OpenURLIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ControlLink&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scan"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;Label&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scan.title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;tableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Intents"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kt"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;systemName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"qrcode.viewfinder"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;LocalizedStringResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scan.title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Intents"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;LocalizedStringResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scan.description"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Intents"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One Live Activity type, JSON state
&lt;/h2&gt;

&lt;p&gt;6pm uses a Live Activity in two situations: a QR payment waiting for you to finish it in your bank app, and a trip in Travel mode. Rather than two &lt;code&gt;ActivityAttributes&lt;/code&gt; types, there is one, and its content state is a JSON string the app has already worded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/targets/widgets/SurfaceAttributes.swift (generated, copied from the module)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;SurfaceAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ActivityAttributes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;ContentState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Codable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Hashable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;/// `pendingPay` or `travel`.&lt;/span&gt;
  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
  &lt;span class="c1"&gt;/// What the activity follows: the waiting spend's id, or the trip's.&lt;/span&gt;
  &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The widget decodes &lt;code&gt;PendingPayState&lt;/code&gt; or &lt;code&gt;TravelState&lt;/code&gt; from it, and lays out the Lock Screen banner and the Dynamic Island's compact, minimal and expanded regions. Later fields are optional, so an activity started by an older build still decodes after an over-the-air update.&lt;/p&gt;

&lt;p&gt;Activities are started by the app, in the foreground, with &lt;code&gt;pushType: nil&lt;/code&gt;. There is no remote push: the app has no notification token to send anywhere.&lt;/p&gt;

&lt;p&gt;The waiting payment's banner has a "Paid" button. It is a &lt;code&gt;LiveActivityIntent&lt;/code&gt;, so it runs in the app's process without opening the app, and it follows the same inbox rule as everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/targets/widgets/_shared/MarkPaidIntent.swift&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;IntentResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuidString&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lowercased&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"v"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"paid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"spendId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;spendId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ISO8601DateFormatter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;GroupFiles&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeInbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;JSONSerialization&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;withJSONObject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="kt"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;SurfaceAttributes&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;.&lt;/span&gt;&lt;span class="n"&gt;activities&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;spendId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;dismissalPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;immediate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the JavaScript side, &lt;code&gt;useLiveActivity&lt;/code&gt; reconciles: on any relevant change it lists what iOS has running and starts, updates or ends activities to match the plan, one run at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Log in 6pm": running TypeScript in an App Intent
&lt;/h2&gt;

&lt;p&gt;The most interesting target is the App Intents extension. It powers Siri ("Log in 6pm") and, more importantly, Apple Pay capture.&lt;/p&gt;

&lt;p&gt;iOS Shortcuts can run an automation when a Wallet card is tapped, and pass the transaction's amount and merchant. 6pm publishes a ready-made shortcut, "Log to 6pm", containing one "Log in 6pm" action with Amount and Where taken from the shortcut input. The user adds it once and attaches it to a Wallet automation. From then on, every Apple Pay tap on that iPhone lands in 6pm's Inbox.&lt;/p&gt;

&lt;p&gt;The intent never starts the app, and it has to return quickly, because Wallet gives up on an automation that takes long:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/targets/intents/LogSpendIntent.swift (trimmed)&lt;/span&gt;
&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;LogSpendIntent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AppIntent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;openAppWhenRun&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="kd"&gt;@Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;LocalizedStringResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"logSpend.amount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Intents"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;

  &lt;span class="kd"&gt;@Parameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;LocalizedStringResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"logSpend.merchant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Intents"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;merchant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;

  &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;IntentResult&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="kt"&gt;ProvidesDialog&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="kt"&gt;ShowsSnippetView&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;logged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;LoggedSpend&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;merchant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;merchant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="c1"&gt;// Only Siri gets here: Wallet always names a merchant, and its unread amounts still log.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;logged&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;needsAmount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;$amount&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;needsValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parsing "25k", "twelve dollars" or Wallet's formatted amount, picking a category from merchant rules, and working out the new "safe today" line are all domain logic. So the extension runs the domain in JavaScriptCore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/targets/_shared/DomainBridge.swift (trimmed)&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;DomainBridge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;/// The `NATIVE_API_VERSION` this Swift was written against.&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int32&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;

  &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Bundle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forResource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"domain-native"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;withExtension&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"js"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;contentsOf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;JSContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;Failure&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;missingBundle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluateScript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// ... reads the global `SixpmDomain`, checks NATIVE_API_VERSION, installs an exception handler&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;/// Calls `SixpmDomain.&amp;lt;name&amp;gt;(...arguments)`, each argument and the result a JSON string.&lt;/span&gt;
  &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LoggedSpend.log&lt;/code&gt; passes the amount, the merchant, the &lt;code&gt;context&lt;/code&gt; file the app last wrote to the group, and the current time. The domain answers with an item to file, which goes into the inbox, plus the text for Siri's snippet. The version check means a Swift/JS mismatch fails loudly instead of logging a wrong number.&lt;/p&gt;

&lt;p&gt;The App Shortcuts provider registers the phrases. Siri has no Vietnamese, so the spoken phrases are English while titles follow the phone's language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/targets/intents/IntentsExtension.swift&lt;/span&gt;
&lt;span class="kt"&gt;AppShortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nv"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;LogSpendIntent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nv"&gt;phrases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"Log in &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;applicationName&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"Log a spend in &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;applicationName&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nv"&gt;shortTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;LocalizedStringResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"logSpend.short"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Intents"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nv"&gt;systemImageName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"plus.circle"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Keeping two languages in sync
&lt;/h2&gt;

&lt;p&gt;Swift holds no copy of a number rule, a word or a colour. &lt;code&gt;tools/scripts/generate-native.ts&lt;/code&gt; writes everything the iOS side reads from the TypeScript packages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;targets/_shared/domain-native.js&lt;/code&gt;: the domain's native entry, bundled with esbuild for JavaScriptCore&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;targets/_shared/&amp;lt;lang&amp;gt;.lproj/&amp;lt;Table&amp;gt;.strings&lt;/code&gt;: the &lt;code&gt;native.*&lt;/code&gt; keys from &lt;code&gt;@6pm/i18n&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;targets/_shared/ThemeTokens.swift&lt;/code&gt;: fixed colours from &lt;code&gt;@6pm/theme&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SurfaceAttributes.swift&lt;/code&gt; and &lt;code&gt;GroupFiles.swift&lt;/code&gt;, copied from the local module so the app and the extensions share one definition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CI runs it with &lt;code&gt;--check&lt;/code&gt; and fails when a generated file is out of date, so a renamed key or a changed colour cannot reach Swift half-done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd tell you before you start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decide the IPC early.&lt;/strong&gt; "Extensions write one JSON file, the app imports it" is boring, and boring is the point. Shared SQLite would trade it for lock-related terminations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Precompute, don't recompute.&lt;/strong&gt; A widget timeline with an entry at the next day boundary means the number is right at 04:00 even if the app hasn't run since yesterday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version every file that crosses the boundary.&lt;/strong&gt; Over-the-air JS updates mean the app can be newer than the extension's expectations, and vice versa.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can you build iOS widgets with Expo?
&lt;/h3&gt;

&lt;p&gt;Yes. &lt;code&gt;@bacons/apple-targets&lt;/code&gt; adds widget, App Intents and share extension targets from a small config file per target, and &lt;code&gt;expo prebuild&lt;/code&gt; generates the Xcode project. The widget UI itself is still SwiftUI.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do the widgets get the data without opening the database?
&lt;/h3&gt;

&lt;p&gt;The app writes a versioned JSON snapshot to the App Group (entries, hourly colours, strings) and reloads timelines. Extensions only ever write single JSON files to an inbox folder, which the app imports.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does 6pm log Apple Pay transactions?
&lt;/h3&gt;

&lt;p&gt;Through a Shortcuts automation on Wallet taps that runs the "Log in 6pm" App Intent with the amount and merchant. The intent runs the domain logic in JavaScriptCore and leaves the spend in the App Group inbox without opening the app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the Live Activity use push notifications?
&lt;/h3&gt;

&lt;p&gt;No. The app starts, updates and ends it locally with &lt;code&gt;pushType: nil&lt;/code&gt;, and the "Paid" button is a &lt;code&gt;LiveActivityIntent&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The code is on GitHub under Apache 2.0: &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;github.com/at6pm/6pm&lt;/a&gt; (start in &lt;code&gt;apps/mobile/targets&lt;/code&gt; and &lt;code&gt;apps/mobile/modules/surfaces&lt;/code&gt;). The widgets, controls and Live Activity are in the public beta on TestFlight: &lt;a href="https://testflight.apple.com/join/a8UrvT5P" rel="noopener noreferrer"&gt;testflight.apple.com/join/a8UrvT5P&lt;/a&gt;. 6pm is on the App Store from Friday 6 November 2026, 18:00 ICT. More at &lt;a href="https://at6pm.app" rel="noopener noreferrer"&gt;at6pm.app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>expo</category>
      <category>ios</category>
      <category>reactnative</category>
      <category>swift</category>
    </item>
    <item>
      <title>Folding it shut closes the day: building a React Native money app for the iPhone Duo</title>
      <dc:creator>Odd Eggs Studio</dc:creator>
      <pubDate>Thu, 22 Oct 2026 03:58:18 +0000</pubDate>
      <link>https://dev.to/oddeggs/folding-it-shut-closes-the-day-building-a-react-native-money-app-for-the-iphone-duo-3kia</link>
      <guid>https://dev.to/oddeggs/folding-it-shut-closes-the-day-building-a-react-native-money-app-for-the-iphone-duo-3kia</guid>
      <description>&lt;p&gt;Apple's iPhone Duo goes on sale on Friday 23 October. 6pm, our daily money app, already lays itself out for each way you can hold it. We built it in mid-October on the iPhone Duo simulator in Xcode 27.1, and it ships in 6pm 2.0.0 on the App Store on 6 November.&lt;/p&gt;

&lt;p&gt;This post covers what the iOS 27.1 SDK gives you, how we got it into React Native, and the one design question that took the most thought: &lt;strong&gt;what should folding a phone shut mean in an app you close once a day?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every snippet is from the open-source repo (&lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;github.com/at6pm/6pm&lt;/a&gt;, Apache 2.0).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the platform gives you
&lt;/h2&gt;

&lt;p&gt;Three things in the iOS 27.1 SDK matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UIHingeInteraction&lt;/code&gt;&lt;/strong&gt;: added to a view, it reports &lt;code&gt;UIHinge&lt;/code&gt; updates with a &lt;code&gt;status&lt;/code&gt; (closed, partially open, fully open) and an &lt;code&gt;angle&lt;/code&gt; in radians.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UIView.reservedRegions(kind:)&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;.division&lt;/code&gt; is the fold across the inner screen, and &lt;code&gt;.occlusion&lt;/code&gt; is the vertical island on the outer screen. Each comes with a frame in the view's space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UIArrangementViewController&lt;/code&gt;&lt;/strong&gt;: split and overlay arrangements for native two-pane apps. 6pm draws its own pages in React Native, so we read the regions instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is &lt;strong&gt;no single "pose" value&lt;/strong&gt;. "Held like a book" and "stood on a table" are both "partially open"; the difference is which way the fold runs across the window. So the pose is yours to work out.&lt;/p&gt;

&lt;p&gt;Some readings from the simulator, which helped us write tests:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Held&lt;/th&gt;
&lt;th&gt;Hinge&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Division&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Closed&lt;/td&gt;
&lt;td&gt;closed, angle 0&lt;/td&gt;
&lt;td&gt;466×678&lt;/td&gt;
&lt;td&gt;none; the island strip at the top right&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Book&lt;/td&gt;
&lt;td&gt;partial, ≈2.23 rad&lt;/td&gt;
&lt;td&gt;951×669&lt;/td&gt;
&lt;td&gt;40 pt upright, at x 455.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flat&lt;/td&gt;
&lt;td&gt;open, π&lt;/td&gt;
&lt;td&gt;951×669&lt;/td&gt;
&lt;td&gt;none once it lies flat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stand&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;669×951&lt;/td&gt;
&lt;td&gt;across, at y 455.5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A small native module, events only when something changes
&lt;/h2&gt;

&lt;p&gt;The Swift side is one Expo module of about 200 lines. It adds an invisible probe view that fills the key window, re-reads the regions whenever the window lays out (folding, turning and Split View all resize it), and holds a &lt;code&gt;UIHingeInteraction&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;interaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIHingeInteraction&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;self&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;hinge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hinge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hinge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hinge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hinge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hinge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"unknown"&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addInteraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interaction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;report()&lt;/code&gt; sends a snapshot to JS only when it differs from the last one, so a scroll that relays the window out sends nothing. The JS thread reads the last snapshot the main thread took, never UIKit itself.&lt;/p&gt;

&lt;p&gt;In JS, the module is optional: &lt;code&gt;requireOptionalNativeModule('Fold')&lt;/code&gt; returns null in tests and on other platforms, and a provider turns it into React state. The angle also goes into a Reanimated shared value, so motion that follows the hinge stays on the UI thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;onFold&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Five layouts from one pure function
&lt;/h2&gt;

&lt;p&gt;Everything that decides layout lives in &lt;code&gt;foldForm(snapshot, window)&lt;/code&gt;, a pure function with Jest tests and no device needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FoldForm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;phone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rail&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;rail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spread&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;rail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;book&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stand&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Half&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Half&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;rail&lt;/strong&gt;: closed, and something covers the window's right edge (the island). The status, Add, the tabs and Scan move into a 72 pt rail on the right, and the sky and the day's number take the full height.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;spread&lt;/strong&gt;: opened sideways with room for two pages of at least 360 pt. It splits at the division, or at the middle with a 40 pt gutter when the screen lies flat and reports no fold. &lt;code&gt;book&lt;/code&gt; is the same spread part way open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stand&lt;/strong&gt;: part way open with the division running across. The top half is for reading, the flat half for touching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;phone&lt;/strong&gt;: everything else, including the Duo opened upright and flat.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One rule from the design governs all of them: &lt;strong&gt;nothing you tap sits in the fold.&lt;/strong&gt; Lists may scroll across it; controls never.&lt;/p&gt;

&lt;p&gt;On the spread, the left page is Today, Plan or the Diary. The right page is the month, a spend you tapped, the Diary's chosen day, or Ask 6pm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design question: what does folding it shut mean?
&lt;/h2&gt;

&lt;p&gt;6pm's core moment is the Close. At 6pm you go through the day's captured spends as cards (Keep, Edit, Not mine), and the day is stamped closed. On the opened Duo, the Close lays out as a book: the cards on the left page, and on the right a ledger with what's been kept and what's still yours today.&lt;/p&gt;

&lt;p&gt;Folding a phone shut is the most natural "I'm done" gesture there is. But in a money app, "done" can't mean "keep whatever I didn't look at". So the rule is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Folding the phone shut finishes the Close &lt;strong&gt;once no card is waiting&lt;/strong&gt;, exactly as the reveal's Done does. With cards still waiting, it carries on on the outer screen, so nothing is ever kept that was not decided.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In code that's a small hook. It fires only on a transition into &lt;code&gt;closed&lt;/code&gt; from part way or fully open, so launching the app on the outer screen never closes anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useFoldShut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onShut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFold&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onShut&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;onShut&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;was&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hinge&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;was&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;was&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hinge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hinge&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;closed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;partial&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hinge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Close screen enables it only after the card step, and the hinge and the Done button share one &lt;code&gt;useFinishClose&lt;/code&gt;. It runs once, works the day's number out for the closed day itself, and lets a failed save be tried again. The outer screen then shows tomorrow's number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Standing it on a counter: Market mode
&lt;/h2&gt;

&lt;p&gt;The stand pose turned out to be the most "real life" one. Half open with the fold across, on Home, 6pm becomes a counter. The top half shows what's left for today in numbers readable from a step away. The flat half has a keypad, the hour's likely categories and Add. Each Add is saved at once as a kept cash spend, the number goes down, and the spend shows as a chip. Think of a morning at the market with cash in hand.&lt;/p&gt;

&lt;p&gt;The same pose powers Scan to pay: the inner camera and a frame to hold the stall's QR up to on the top half, the confirm and the bank hand-off on the flat half. While a payment is under way, the top half shows the amount and payee large enough for the seller to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Check the SDK, not the compiler.&lt;/strong&gt; We first guarded the 27.1 calls with &lt;code&gt;#if compiler(&amp;gt;=6.4)&lt;/code&gt;. Then Xcode 27.0 shipped the same Swift 6.4 with the iOS 27.0 SDK, and the build broke there. UIKit's module version follows the SDK, so that's what we check now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if canImport(UIKit, _version: 9127.0.85)  // iOS 27.1's UIKit&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;#available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iOS&lt;/span&gt; &lt;span class="mf"&gt;27.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// UIHingeInteraction, reservedRegions(kind:)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="cp"&gt;#endif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;#available&lt;/code&gt; still guards the runtime, for people on older iOS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An older SDK means no hinge, silently.&lt;/strong&gt; A build from an older Xcode runs fine on the Duo, with one page everywhere. Our CI built signed apps with Xcode 26.6, so we moved signed builds to the Xcode 27 runner image, picking 27.1 once it's a release build. Never a beta: App Store Connect refuses uploads built with one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inner screen turns on its own.&lt;/strong&gt; iOS ignores the portrait lock there, so every inner-screen layout has to work both ways round.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The simulator folds from Device Hub, not &lt;code&gt;simctl&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;simctl&lt;/code&gt; has no posture command. We drove the hinge from scripts with &lt;code&gt;agent-device fold closed|half-open|open&lt;/code&gt;, which sends the hinge event inside the simulator and reads the angle back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every pose change animates
&lt;/h2&gt;

&lt;p&gt;Pages slide apart and the rail eases in; nothing jumps. The order of things is the same on both screens, so shutting the phone part way through keeps your place.&lt;/p&gt;

&lt;p&gt;6pm 2.0.0 is on the App Store from Friday 6 November, 18:00 ICT. Until then the beta is on TestFlight (&lt;a href="https://testflight.apple.com/join/a8UrvT5P" rel="noopener noreferrer"&gt;join here&lt;/a&gt;), and the code is at &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;github.com/at6pm/6pm&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance from our own code, commits and design docs; every snippet is from the repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ios</category>
      <category>expo</category>
      <category>swift</category>
    </item>
    <item>
      <title>Reading VietQR on the phone: TLV, NAPAS and 28 bank apps</title>
      <dc:creator>Odd Eggs Studio</dc:creator>
      <pubDate>Wed, 21 Oct 2026 15:59:21 +0000</pubDate>
      <link>https://dev.to/oddeggs/reading-vietqr-on-the-phone-tlv-napas-and-28-bank-apps-4ghl</link>
      <guid>https://dev.to/oddeggs/reading-vietqr-on-the-phone-tlv-napas-and-28-bank-apps-4ghl</guid>
      <description>&lt;p&gt;6pm reads a VietQR entirely on the phone: a small TLV reader, a CRC-16/CCITT-FALSE check, a NAPAS template lookup. Then it opens the payer's bank app. For 28 apps that is a transfer link with account, amount and note filled in; for the rest, the app's home screen, the amount on the clipboard and the QR saved to Photos.&lt;/p&gt;

&lt;p&gt;This post walks through the code in &lt;code&gt;packages/domain/src/vietqr/&lt;/code&gt; and &lt;code&gt;apps/mobile/src/features/scan/bank-handoff.ts&lt;/code&gt;. Everything quoted is from the repo, which is Apache 2.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why parse it ourselves
&lt;/h2&gt;

&lt;p&gt;6pm is an expense tracker that never asks for a bank login. In Vietnam most small payments are VietQR transfers: a printed code at the bánh mì stall, scanned by a bank app. If 6pm does the scanning, it knows the payee, the amount and the note before the money moves, and the spend lands in the Inbox without anyone typing it.&lt;/p&gt;

&lt;p&gt;That only works if the parser is boringly correct. A wrong amount in a money app is worse than no amount. So the parser lives in &lt;code&gt;@6pm/domain&lt;/code&gt;, a plain TypeScript package with no React Native in it, tested in Vitest, and bundled into the app and its iOS extensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The format in two minutes
&lt;/h2&gt;

&lt;p&gt;VietQR is NAPAS's profile of the EMVCo merchant-presented QR. The payload is a flat string of data objects, each &lt;code&gt;ID (2 digits) · length (2 digits) · value&lt;/code&gt;. Some values are themselves TLV strings. A typical transfer looks like this, broken up for reading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;00 02 01                      payload format indicator
01 02 12                      12 = dynamic (one-off), 11 = static
38 57 0010A000000727          merchant account template, NAPAS GUID
      0127 0006970422 0113…   beneficiary: BIN 970422 + account
      0208QRIBFTTA            service: transfer to an account
53 03 704                     currency: VND
54 05 55000                   amount
58 02 VN                      country
62 .. 08 .. Banh mi           additional data, 08 = purpose
63 04 ABCD                    CRC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The merchant account template can sit in any tag from 26 to 51. NAPAS's has GUID &lt;code&gt;A000000727&lt;/code&gt; in sub-tag 00, a nested beneficiary in 01 (BIN in 00, account in 01), and a service code in 02: &lt;code&gt;QRIBFTTA&lt;/code&gt; (account), &lt;code&gt;QRIBFTTC&lt;/code&gt; (card) or &lt;code&gt;QRPUSH&lt;/code&gt; (merchant).&lt;/p&gt;

&lt;h2&gt;
  
  
  A TLV reader that survives real codes
&lt;/h2&gt;

&lt;p&gt;The reader is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/domain/src/vietqr/tlv.ts (trimmed)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseTlv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LengthUnit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chars&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;TlvFields&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;TWO_DIGITS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;TWO_DIGITS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;want&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;taken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taken&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;want&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;taken&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bytes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;utf8Size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nf"&gt;codePointAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taken&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;want&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Array.from(text)&lt;/code&gt;, not indexing.&lt;/strong&gt; EMVCo counts characters. JavaScript strings index UTF-16 code units, so an emoji in a note would shift every later field. Iterating code points keeps the count honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;bytes&lt;/code&gt; mode.&lt;/strong&gt; Some Vietnamese generators count UTF-8 bytes when the note has diacritics ("Bánh mì" is 7 characters and 9 bytes). Strict parsing would reject codes that every bank app accepts. So &lt;code&gt;parseVietQR&lt;/code&gt; tries characters first, then bytes, and takes whichever reading ends cleanly on tag 63:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/domain/src/vietqr/parse-vietqr.ts&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readFields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TlvFields&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LengthUnit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chars&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bytes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseTlv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()].&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;63&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same unit is then used for the nested templates, so a code is read consistently one way or the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checksum
&lt;/h2&gt;

&lt;p&gt;Tag 63 is a CRC-16/CCITT-FALSE over everything before it, including the literal &lt;code&gt;6304&lt;/code&gt;. The implementation is the textbook bitwise version over UTF-8 bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/domain/src/vietqr/crc16.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;crc16CcittFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0xffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byte&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;utf8Bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="nx"&gt;byte&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;bit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;bit&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;bit&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x8000&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="mh"&gt;0x1021&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xffff&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;parseVietQR&lt;/code&gt; checks it before reading any field. A failed CRC is its own outcome, &lt;code&gt;bad_crc&lt;/code&gt;, so the interface can say "that code looks damaged" rather than "that isn't a payment".&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the payment, refusing the rest
&lt;/h2&gt;

&lt;p&gt;After the CRC, the parser finds the NAPAS template among tags 26–51, rejects anything that is not đồng (&lt;code&gt;53&lt;/code&gt; must be &lt;code&gt;704&lt;/code&gt; when present) or not Vietnam (&lt;code&gt;58&lt;/code&gt; must be &lt;code&gt;VN&lt;/code&gt; when present), and validates the beneficiary: a six-digit BIN and an account of 1–19 letters and digits.&lt;/p&gt;

&lt;p&gt;Amounts get special care because VND has no minor unit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AMOUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\d{1,13})(?:\.&lt;/span&gt;&lt;span class="sr"&gt;0*&lt;/span&gt;&lt;span class="se"&gt;)?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// "55000" and "55000.00" are both 55.000₫; "55000.50" cannot be paid: malformed.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A zero or missing amount is &lt;code&gt;null&lt;/code&gt;, meaning "ask the user". The service code is mandatory in NAPAS's spec, but older static codes in the wild omit it, so a missing 02 is read as a transfer to an account.&lt;/p&gt;

&lt;p&gt;The result is a discriminated union. Success carries the bank (looked up by BIN), the masked account (&lt;code&gt;•••• 4821&lt;/code&gt;), the amount, the note from 62/08, and whether the code is dynamic. Failure carries one of &lt;code&gt;not_emv&lt;/code&gt;, &lt;code&gt;bad_crc&lt;/code&gt;, &lt;code&gt;foreign&lt;/code&gt;, &lt;code&gt;unsupported&lt;/code&gt; or &lt;code&gt;malformed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Foreign codes: name them, never pay them
&lt;/h2&gt;

&lt;p&gt;Travellers scan codes abroad, and Vietnamese visitors scan Thai and Singaporean codes at home. 6pm recognises them and keeps the spend in its own currency, but never tries to pay them.&lt;/p&gt;

&lt;p&gt;Detection is by the GUID in the merchant template, falling back to the country in tag 58:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/domain/src/vietqr/foreign-scheme.ts (excerpt)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GUIDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A000000677&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;promptpay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SG.PAYNOW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SG&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paynow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A0000006150001&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;duitnow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ID.CO.QRIS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qris&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PH.PPMI&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qrph&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HK.COM.HKICL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fps&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BR.GOV.BCB.PIX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pix&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Codes that are not EMV at all (UPI links, SEPA transfer codes, the Swiss QR-bill, Czech QR Platba, Alipay and WeChat Pay shop codes) go through &lt;code&gt;readOtherPayment&lt;/code&gt; before the EMV path. A small set, &lt;code&gt;PAID_FROM_VIETNAM = new Set(['TH', 'SG', 'MY', 'KH', 'LA'])&lt;/code&gt;, records which countries' codes a Vietnamese bank app can pay across the border, so Travel mode can say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handing off to the bank
&lt;/h2&gt;

&lt;p&gt;6pm does not move money. After a scan it hands the payment to the bank app the user picks, from a catalogue of 41 Vietnamese bank and wallet apps whose iOS URL scheme it knows. This is the part that took the most poking at real phones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the links came from
&lt;/h3&gt;

&lt;p&gt;Zalo has a "pay in a bank app" sheet. Watching which URLs it opens gave us each bank's URL scheme and, for most, a transfer link of one shared shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scheme"&gt;&lt;code&gt;&lt;span class="nv"&gt;&amp;lt;scheme&amp;gt;applink?targetPage=QRPay&amp;amp;qrContent=&amp;lt;url-encoded&lt;/span&gt; &lt;span class="nv"&gt;VietQR&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;VietinBank uses the same query on a different path (&lt;code&gt;host.qrTransfer&lt;/code&gt;). Nam A Bank takes no QR at all, but a base64 JSON blob of the fields on &lt;code&gt;ops.namabank.com.vn/?data=&lt;/code&gt;. The catalogue encodes this per bank:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/domain/src/vietqr/bank-catalogue.ts (excerpt)&lt;/span&gt;
&lt;span class="nx"&gt;MB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nf"&gt;linked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mbbank://&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1205807363&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nx"&gt;VCB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vietcombankmobile://&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;561433133&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nx"&gt;VBA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agribankmobile://&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;935944952&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nx"&gt;ICB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;linked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vietinbankipay://&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;689963454&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qrPay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;host.qrTransfer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="nx"&gt;NAB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;linked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nabtransfermoney://&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1456997296&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;namABank&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-eight entries are &lt;code&gt;linked&lt;/code&gt;. Zalo signs its links; 6pm sends them unsigned. MB is the bank we have confirmed opens filled in that way with a transfer completed end to end on a real phone. Vietcombank and Agribank take only a token issued to their partners, so they are &lt;code&gt;plain&lt;/code&gt; and open at their home screen. MoMo and ZaloPay open by the schemes their own SDKs query, with no transfer link; ViettelMoney and VNPT Money publish no scheme, so 6pm tells the user to open them and copies the amount.&lt;/p&gt;

&lt;h3&gt;
  
  
  The QR going in
&lt;/h3&gt;

&lt;p&gt;When the user types an amount for a static code, the bank should see that amount. &lt;code&gt;payloadFor&lt;/code&gt; re-encodes a person-to-person transfer with the new amount, but never rewrites a shop's code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/domain/src/vietqr/handoff-link.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;payloadFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VietQR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Vnd&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;qr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;merchant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;qr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// …re-encode bin, account, amount, note, name with encodeVietQR; on failure, qr.raw&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;encodeVietQR&lt;/code&gt; is the mirror of the parser: it builds the TLV, appends &lt;code&gt;6304&lt;/code&gt;, and computes the CRC with the same function.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hand-off itself
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/features/scan/bank-handoff.ts (trimmed)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handOff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BankInfo&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;qr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VietQR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setStringAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;handoffPlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;OS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;qr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qrSaved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;filledIn&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveQr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;payloadFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;qrSaved&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;opens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;opened&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;qrSaved&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;opens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;qrSaved&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;qrSaved&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions are encoded here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The amount always goes on the clipboard&lt;/strong&gt;, digits only, because that is what a bank's amount field accepts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;canOpenURL&lt;/code&gt;.&lt;/strong&gt; It is deprecated for this use and limited to 25 declared schemes, and the catalogue has 41 apps. &lt;code&gt;Linking.openURL&lt;/code&gt; fails just as clearly when the app is missing, and the fallback is its App Store page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the bank cannot be filled in, the QR is saved to Photos&lt;/strong&gt;, drawn by our own QR encoder (&lt;code&gt;encode-qr.ts&lt;/code&gt;, byte mode, Reed-Solomon, mask scoring per ISO/IEC 18004), so the bank's "scan from photo" reads it back exactly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Android, &lt;code&gt;handoffPlan&lt;/code&gt; opens the app's home: no bank's transfer link has been seen to open filled in there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coming back: "Did it go through?"
&lt;/h2&gt;

&lt;p&gt;No bank app returns to 6pm on its own. When the user comes back, 6pm asks "Did it go through?". Until then the scan is a pending spend, shown as a Live Activity with a Paid button, and it already lowers the day's safe-to-spend number so the number stays honest.&lt;/p&gt;

&lt;p&gt;If the bank's own receipt also arrives (a shared screenshot, a forwarded email, an Apple Pay tap), the two are merged rather than counted twice. Most sources merge within three minutes; a waiting scan gets longer, because people finish paying in the bank app a while later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/domain/src/capture/duplicate.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DUPLICATE_WINDOW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="cm"&gt;/** A QR scan still waiting to be paid is matched by its bank's receipt … up to this long after the tap. Never before the tap. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;QR_RECEIPT_WINDOW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything the user typed (Quick add, voice, cash) is never merged with anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would tell someone building this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat "the spec says" and "what is printed on the stall" as two specs. Byte-counted lengths and missing service codes are both real.&lt;/li&gt;
&lt;li&gt;Keep the parser pure and away from the camera. Ours runs in Vitest, in the app, and in an iOS extension through JavaScriptCore without changes.&lt;/li&gt;
&lt;li&gt;Return reasons, not booleans. &lt;code&gt;bad_crc&lt;/code&gt;, &lt;code&gt;foreign&lt;/code&gt; and &lt;code&gt;unsupported&lt;/code&gt; each produce different words on screen.&lt;/li&gt;
&lt;li&gt;Do not promise that every bank opens filled in. Ours do not, and the interface says so.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does 6pm send the scanned QR to a server?
&lt;/h3&gt;

&lt;p&gt;No. The QR is parsed on the phone in &lt;code&gt;@6pm/domain&lt;/code&gt;. The only thing that leaves is the URL the bank app is opened with, which goes to that app on the same phone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why are the bank links sent unsigned?
&lt;/h3&gt;

&lt;p&gt;Zalo's links carry its own signature, which we cannot produce. The links work unsigned for the banks we have tested, MB end to end; banks that require a partner token (Vietcombank, Agribank) open at their home screen instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can 6pm pay a PromptPay or PayNow code?
&lt;/h3&gt;

&lt;p&gt;No. It recognises the scheme and records the spend in that currency. With Travel mode on from a đồng home, it says whether a Vietnamese bank app can pay that country's codes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the parser reusable?
&lt;/h3&gt;

&lt;p&gt;It is plain TypeScript with no React Native dependency, under Apache 2.0, in &lt;code&gt;packages/domain/src/vietqr/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;github.com/at6pm/6pm&lt;/a&gt;. The iPhone app is in public beta on TestFlight: &lt;a href="https://testflight.apple.com/join/a8UrvT5P" rel="noopener noreferrer"&gt;testflight.apple.com/join/a8UrvT5P&lt;/a&gt;, and on the App Store from 6 November 2026, 18:00 ICT. More at &lt;a href="https://at6pm.app" rel="noopener noreferrer"&gt;at6pm.app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>ios</category>
      <category>fintech</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How we made an Expo app feel native: Reanimated 4, real form sheets and haptics</title>
      <dc:creator>Odd Eggs Studio</dc:creator>
      <pubDate>Wed, 21 Oct 2026 12:08:40 +0000</pubDate>
      <link>https://dev.to/oddeggs/how-we-made-an-expo-app-feel-native-reanimated-4-real-form-sheets-and-haptics-244c</link>
      <guid>https://dev.to/oddeggs/how-we-made-an-expo-app-feel-native-reanimated-4-real-form-sheets-and-haptics-244c</guid>
      <description>&lt;p&gt;An Expo app can feel native if every animation runs on the UI thread, every gesture hands its velocity to the spring that finishes it, and you use the system's own sheets instead of drawing your own. In 6pm that means Reanimated 4 worklets, Gesture Handler, Expo Router form sheets, and four kinds of haptic, each used for one job.&lt;/p&gt;

&lt;p&gt;6pm is a daily money app. Its core moment, "the close", is a stack of receipt cards you swipe through at 6pm: right to keep, left to edit, down for "not mine". The last card stamps the day CLOSED. If that swipe feels like a web page, the product fails. So our agent contract (the &lt;code&gt;CLAUDE.md&lt;/code&gt; at the repo root) opens with this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Native feel is the product.&lt;/strong&gt; UI-thread animation only (Reanimated worklets, Gesture Handler). Every state change animates with the prototype's curve. No layout jumps, no blank frames, no JS-thread &lt;code&gt;Animated&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here is how that rule turns into code. Versions, from the workspace catalog: Expo 58, React Native 0.88 (New Architecture, Hermes), &lt;code&gt;react-native-reanimated&lt;/code&gt; 4.7.0, &lt;code&gt;react-native-worklets&lt;/code&gt; 0.13.0, &lt;code&gt;react-native-gesture-handler&lt;/code&gt; 3.2.1, &lt;code&gt;expo-router&lt;/code&gt; 58.0.19, &lt;code&gt;expo-haptics&lt;/code&gt; 58.0.6.&lt;/p&gt;

&lt;h2&gt;
  
  
  Motion is data, not taste
&lt;/h2&gt;

&lt;p&gt;The design started as an HTML prototype with CSS keyframes and cubic-beziers. Rather than eyeballing it, the curves, durations, springs and gesture thresholds live in one place, &lt;code&gt;@6pm/theme&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/theme/src/motion.ts (trimmed)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;curves&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/** The house curve: a confident move with a small overshoot (cards, sheets, chips). */&lt;/span&gt;
  &lt;span class="na"&gt;house&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.12&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="cm"&gt;/** Entrances: screens, list items, hero cards. */&lt;/span&gt;
  &lt;span class="na"&gt;enter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="cm"&gt;/** Wheel pickers snapping to a row. */&lt;/span&gt;
  &lt;span class="na"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gestures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cardFling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rowConfirm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rowRevealDelete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rowCommitDelete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sheetDismiss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tabSwipe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fabLongPressMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;480&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Screens import the numbers, never invent them, so motion can be reviewed like any other diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Release physics: project where the finger was going
&lt;/h2&gt;

&lt;p&gt;The biggest difference between "animated" and "native" is what happens when the finger lets go. UIKit decides with velocity, not just position: a quick short flick commits, a slow long drag doesn't. We use the same projection UIScrollView uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/ui/motion/physics.ts&lt;/span&gt;
&lt;span class="cm"&gt;/**
 * Where a released finger was heading: the distance it would still travel if it kept slowing at
 * UIScrollView's normal rate. A quick short flick projects far and commits; a slow long drag
 * doesn't. `velocity` in points per second, as Gesture Handler reports it.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;velocity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decelerationRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.998&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;worklet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;decelerationRate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;decelerationRate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The close card uses it to pick a verdict. Only the axis the finger mostly travelled counts, so a mostly downward drag that drifts right is "not mine", never "keep":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/features/close/card-fling.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cardFling&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;velocityX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;velocityY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;CardFling&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;worklet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Read here, not as a default parameter: a worklet captures only names its body uses.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reach&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;gestures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cardFling&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;velocityX&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;py&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;velocityY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;py&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;reach&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kept&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;reach&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;edit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;py&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;reach&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notMine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comment about default parameters is a real gotcha: a worklet closure captures only what its body references, so a default parameter that reads an imported constant can come up undefined on the UI thread. The function is pure, so it has a unit test (&lt;code&gt;card-fling.test.ts&lt;/code&gt;) that runs in Jest without a device.&lt;/p&gt;

&lt;h2&gt;
  
  
  The swipe itself
&lt;/h2&gt;

&lt;p&gt;The card stack is a &lt;code&gt;Gesture.Pan()&lt;/code&gt; writing to two shared values. Three details make it feel physical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/features/close/close-cards.tsx (trimmed)&lt;/span&gt;
&lt;span class="nx"&gt;Gesture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onStart&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// A card caught mid-flight is picked up where it is.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;startX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;translationX&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;startY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;unband&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;translationY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onUpdate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;startY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;translationY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;translationX&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rubberband&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onEnd&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cardFling&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;velocityX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;velocityY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;velocityX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;velocityY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// The tap lands as the finger lets go, not when the card is gone.&lt;/span&gt;
    &lt;span class="nf"&gt;scheduleOnRN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;haptics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;away&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;release&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;away&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reduceMotion&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kept&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withSpring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AWAY_X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;away&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;velocity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;velocityX&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;withSpring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AWAY_Y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;away&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;velocity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;velocityY&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Interruptible.&lt;/strong&gt; Grab a card that is still springing home and it is picked up where it is. Because upward drags are rubber-banded, &lt;code&gt;onStart&lt;/code&gt; runs the inverse (&lt;code&gt;unband&lt;/code&gt;) to find the finger offset that produces the current position. No jump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rubber, not walls.&lt;/strong&gt; Dragging up, the card follows less and less, the same curve iOS uses at a scroll view's edge:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;   &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rubberband&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overshoot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dimension&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;constant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;worklet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overshoot&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dimension&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;constant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dimension&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;constant&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overshoot&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Velocity continuity.&lt;/strong&gt; Every release spring takes the finger's velocity, so there is no seam between drag and settle. The springs are duration-and-damping-ratio springs, which are far easier to match to a design than stiffness and mass:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;   &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;release&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="na"&gt;home&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dampingRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
     &lt;span class="na"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dampingRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
     &lt;span class="na"&gt;away&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dampingRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;overshootClamping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The card tilts as it moves (&lt;code&gt;rotate: dx / 18&lt;/code&gt; degrees), computed in &lt;code&gt;useAnimatedStyle&lt;/code&gt;. And because each new top card mounts with its own drag at zero, nothing is "reset" after a fling: the card behind is already where the stack had moved it.&lt;/p&gt;

&lt;p&gt;The buttons under the card (Keep, Edit, Not mine) do the same thing without a finger. They have no velocity to carry, so they use &lt;code&gt;withTiming&lt;/code&gt; on the house ease-out instead of a spring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real sheets, not drawn ones
&lt;/h2&gt;

&lt;p&gt;At first, every sheet in 6pm drew itself over a transparent modal with Reanimated. It looked right in screenshots and felt wrong in the hand. On 12 October we switched to the real thing. The reasoning is recorded in &lt;code&gt;docs/tech-decisions.md&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The native sheet drags from anywhere, pulls down from the top of a scroll, carries the finger's velocity, is interruptible, honours Reduce Motion, and slides away on &lt;code&gt;router.back()&lt;/code&gt;; the hand-drawn one did none of that well.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With Expo Router that is a screen option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/ui/navigation/presentations.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ScreenOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;presentation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ios&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPad&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pageSheet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;formSheet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sheetAllowedDetents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;sheetCornerRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sheetGrabberVisible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headerShown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contentStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transparent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;code&gt;UISheetPresentationController&lt;/code&gt; underneath (via react-native-screens). Trade-offs we accepted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4 points.&lt;/strong&gt; The one detent is the tallest iOS allows. Its top edge sits on the safe area, 4 pt lower than the prototype's. iOS won't raise it, and we decided 4 pt was a better deal than fake physics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our own grabber.&lt;/strong&gt; We hide the system grabber and draw the prototype's, and each sheet paints its own background.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iPad gets a page sheet.&lt;/strong&gt; On iPad a form sheet is a small centred card, too short for a pinned footer, so there it becomes a page sheet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask and Scan are page sheets on iPhone too.&lt;/strong&gt; The page behind scales back into a card, as Mail does when you compose, which suits a screen that is "a place of its own".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two sheets stay hand-drawn because iOS cannot draw them: a floating card inset from the edges, and the Looks sheet, which sinks low over an undimmed page while you try a theme on. Below its tallest detent, iOS floats its sheet inset from the edges, so neither shape can be native. Those use a shared drag-to-dismiss hook that follows the same rules as the native sheet: decide by where the flick was heading (position plus projected velocity), spring home at &lt;code&gt;{300 ms, ratio 0.8}&lt;/code&gt; or out at &lt;code&gt;{300 ms, ratio 1, clamped}&lt;/code&gt; with the finger's velocity, and continue from wherever it is if grabbed mid-flight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stamp
&lt;/h2&gt;

&lt;p&gt;When the last card goes, the day prints: a receipt feeds down out of a slot, and 1.6 s later a red CLOSED stamp lands. The prototype's &lt;code&gt;stampIn&lt;/code&gt; keyframes (scale 2.4 → 0.94 → 1, rotate −14° → −7°, opacity to 0.92) become one shared value driven by a sequence, with the keyframe maths in the animated style:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/features/close/close-receipt.tsx (trimmed)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stampEase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Easing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STAMP_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;land&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;STAMP_MS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;withSequence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;withTiming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;STAMP_MS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;land&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;easing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stampEase&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;runOnJS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;haptics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;)(),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;withTiming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;STAMP_MS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stampStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAnimatedStyle&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;2.4&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.4&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.94&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.94&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;deg`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The haptic fires in the timing's completion callback, the instant the stamp hits paper. That timing is the whole trick: a thump 100 ms late reads as a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Haptics: four words, used consistently
&lt;/h2&gt;

&lt;p&gt;The prototype had one &lt;code&gt;buzz()&lt;/code&gt;. iOS offers several feelings, so we split it into a tiny vocabulary and never call &lt;code&gt;expo-haptics&lt;/code&gt; directly from a screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/ui/haptics.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;haptics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/** Tab scrub, wheel row, a swipe crossing its threshold. */&lt;/span&gt;
  &lt;span class="na"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;Haptics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;selectionAsync&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="cm"&gt;/** Keep / edit / not mine, FAB long-press, sheet dismissed, undo. */&lt;/span&gt;
  &lt;span class="na"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;Haptics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;impactAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Haptics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ImpactFeedbackStyle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Light&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="cm"&gt;/** Something let go of for good: a row flung away to delete. */&lt;/span&gt;
  &lt;span class="na"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;Haptics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;impactAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Haptics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ImpactFeedbackStyle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Medium&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="cm"&gt;/** The close done, the reveal settling, a purchase made. */&lt;/span&gt;
  &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;Haptics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notificationAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Haptics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NotificationFeedbackType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From a gesture worklet, haptics hop to the JS thread with &lt;code&gt;scheduleOnRN&lt;/code&gt; (Reanimated 4 moved thread hopping into &lt;code&gt;react-native-worklets&lt;/code&gt;). Two rules we follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fire on the finger, not on the animation.&lt;/strong&gt; The card's tap lands as the finger lets go, not 300 ms later when the card leaves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Success is rare.&lt;/strong&gt; Only finishing things gets the success pattern: the close, the reveal of tomorrow's number, a purchase. If everything buzzes, nothing does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Numbers that roll instead of jumping
&lt;/h2&gt;

&lt;p&gt;"Every state change animates" includes text. The daily number is an odometer: each digit is a column that rolls, 1000 ms plus 80 ms per position, on the entrance curve. When the number grows a digit (99 → 100), the new column opens from zero width while the others glide over, using Reanimated layout animations written as worklets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/ui/odometer.tsx&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slide&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LinearTransition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;durations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;easing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No re-render can make the number jump, because the layout change itself is animated on the UI thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reduce Motion is part of the design
&lt;/h2&gt;

&lt;p&gt;Respecting the accessibility setting is not an afterthought you add with a flag at the end. In 6pm:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Release springs pass &lt;code&gt;reduceMotion: ReduceMotion.System&lt;/code&gt;, so iOS's setting is honoured in the spring itself.&lt;/li&gt;
&lt;li&gt;The motion contract in &lt;code&gt;docs/tech-decisions.md&lt;/code&gt; says springs become short fades, atmosphere freezes and nothing loops.&lt;/li&gt;
&lt;li&gt;The animated sky's clock stays at zero under Reduce Motion, which holds every drift, twinkle and grain step on its first frame.&lt;/li&gt;
&lt;li&gt;Native sheets honour it for free, one more reason to use them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Put the physics in pure worklet functions.&lt;/strong&gt; &lt;code&gt;project&lt;/code&gt;, &lt;code&gt;rubberband&lt;/code&gt;, &lt;code&gt;unband&lt;/code&gt; and &lt;code&gt;cardFling&lt;/code&gt; are a few lines each, run on the UI thread, and are unit-tested in Jest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer the platform when it exists.&lt;/strong&gt; We drew our sheets first, then moved every sheet iOS can present to &lt;code&gt;presentation: 'formSheet'&lt;/code&gt;. The 4 pt we lost bought interruptibility, scroll hand-off and accessibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Haptics are timing.&lt;/strong&gt; The right pattern at the wrong moment is worse than none.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can a React Native app feel as smooth as a native one?
&lt;/h3&gt;

&lt;p&gt;Yes, if animation and gesture handling run on the UI thread. Reanimated worklets and Gesture Handler do that; the JS-thread &lt;code&gt;Animated&lt;/code&gt; API and state-driven re-renders don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use native form sheets or a JS bottom sheet in Expo?
&lt;/h3&gt;

&lt;p&gt;Use Expo Router's &lt;code&gt;presentation: 'formSheet'&lt;/code&gt; where it fits: you get UISheetPresentationController's drag, scroll hand-off, velocity and Reduce Motion for free. Draw your own only for shapes iOS can't present.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you decide if a swipe should commit?
&lt;/h3&gt;

&lt;p&gt;Project the release velocity with UIScrollView's deceleration rate, add it to the current offset, and compare against a threshold on the dominant axis. A short fast flick commits; a slow drag springs back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;All of this is open source under Apache 2.0: &lt;a href="https://github.com/at6pm/6pm" rel="noopener noreferrer"&gt;github.com/at6pm/6pm&lt;/a&gt; (see &lt;code&gt;apps/mobile/src/ui/motion&lt;/code&gt;, &lt;code&gt;apps/mobile/src/features/close&lt;/code&gt; and &lt;code&gt;packages/theme/src/motion.ts&lt;/code&gt;). Swipe the close yourself in the public beta on TestFlight: &lt;a href="https://testflight.apple.com/join/a8UrvT5P" rel="noopener noreferrer"&gt;testflight.apple.com/join/a8UrvT5P&lt;/a&gt;. 6pm is on the App Store from Friday 6 November 2026, 18:00 ICT. More at &lt;a href="https://at6pm.app" rel="noopener noreferrer"&gt;at6pm.app&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance from our own code and design docs; every snippet is from the repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ios</category>
      <category>animation</category>
    </item>
  </channel>
</rss>
