<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Treamz]]></title><description><![CDATA[Treamz]]></description><link>https://treamz.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 15 Nov 2026 18:08:19 GMT</lastBuildDate><atom:link href="https://treamz.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Offline-first aquarium log in Flutter with Drift and Supabase]]></title><description><![CDATA[Water tests in Flutter: blank readings that never become zero, free ammonia from pH and temperature, canonical units, Drift-to-Supabase sync.]]></description><link>https://treamz.hashnode.dev/offline-first-aquarium-log-in-flutter-with-drift-and-supabase</link><guid isPermaLink="true">https://treamz.hashnode.dev/offline-first-aquarium-log-in-flutter-with-drift-and-supabase</guid><category><![CDATA[Flutter]]></category><category><![CDATA[Dart]]></category><category><![CDATA[supabase]]></category><category><![CDATA[offline first]]></category><dc:creator><![CDATA[Ivan Chernoknizhnikov]]></dc:creator><pubDate>Thu, 12 Nov 2026 17:33:20 GMT</pubDate><content:encoded><![CDATA[<p>This post is about the data model behind my aquarium app: readings where a
blank field never turns into a zero, free ammonia worked out from pH and
temperature, one stored unit per parameter, and a local Drift database that
syncs to Supabase through an outbox. The app is
<a href="https://apps.apple.com/us/app/aquarium-log-tracker/id6804384133">Aquarium - Log Tracker</a>,
a free iPhone logbook for water tests, written in Flutter. What it does for
fishkeepers is in <a href="https://treamz.me/blog/aquarium-water-test-log-app">the user-facing post</a>;
this one is the code.</p>
<h2>Blank is not zero</h2>
<p>A water test is a handful of numbers typed standing at a tank, often with wet
hands, and nobody tests everything every time. So the first real decision was
what an empty field means. The answer is nothing. There is no nullable value
column: a parameter you didn't test produces no row at all.</p>
<pre><code class="language-dart">for (final setting in settings) {
  final text = _controllers[setting.key]?.text.trim() ?? '';
  // An untouched field produces no reading at all. Never zero.
  if (text.isEmpty) continue;

  final value = double.tryParse(text.replaceAll(',', '.'));
  if (value == null) {
    setState(() =&gt; _error = '${setting.definition.displayName}: "$text" is not a number');
    return;
  }

  entries.add(ReadingDraft(parameterKey: setting.key, value: value,
      unit: _units[setting.key] ?? ref.read(unitsProvider).unitFor(setting.definition)));
}
</code></pre>
<p>The previous value sits in each field as <code>hintText</code>, not as text, so it's a
reference you can't save by accident.</p>
<p>This matters most for rate of change. Each saved reading is compared with the
latest reading of the same parameter, which is by construction the last time
that thing was actually measured. The default reef rules allow alkalinity to
move 0.5 dKH a day. Store a skipped test as zero and the next honest 8.4 dKH
looks like the tank jumped 8.4 dKH overnight. Two related rules: no daily rate
from readings less than 12 hours apart, because over an hour the difference is
mostly the test kit, and no pH drift limit for planted tanks, where CO₂
injection swings pH every day on purpose.</p>
<p>Charts follow from the same model. Each parameter gets its own row and scale
on a shared time axis, with points spaced by date and a dot on every real
measurement. A month without a nitrate test is a long straight segment between
two dots, not a dip to zero. That segment is still an interpolation the eye
can read as data; making the dots obvious is the best answer I have.</p>
<h2>Absence of a measurement is not a measurement</h2>
<p>Nulls do appear in one place: when rows are folded into per-session snapshots
to work out where a new tank is in its nitrogen cycle. A snapshot holds
nullable ammonia, nitrite, nitrate, pH, KH and temperature, and a null never
counts as clear or present.</p>
<pre><code class="language-dart">bool get ammoniaClear =&gt; ammonia != null &amp;&amp; ammonia! &lt;= Cycling.effectivelyZero;
bool get nitriteClear =&gt; nitrite != null &amp;&amp; nitrite! &lt;= Cycling.effectivelyZero;
bool get nitratePresent =&gt; nitrate != null &amp;&amp; nitrate! &gt; 0;

bool get passesCompletionTest =&gt; ammoniaClear &amp;&amp; nitriteClear &amp;&amp; nitratePresent;

// In Cycling.assess: the stage comes from the newest session that measured
// ammonia or nitrite, not simply the newest session.
final latest = sorted.lastWhere(
  (s) =&gt; s.ammonia != null || s.nitrite != null,
  orElse: () =&gt; sorted.last,
);
</code></pre>
<p>A tank counts as cycled after two passing sessions in a row, because one clean
result can be a misread. Nitrate is required so that "nothing was ever dosed"
can't pass for "the bacteria are working". "Clear" means at or below 0.25 ppm,
because my research notes flag a common hobby kit as reading about that on
clean water.</p>
<p>The <code>lastWhere</code> exists because of a bug I found by looking at the screen, not
with a test. After logging a single alkalinity reading, the cycling banner
dropped back to "Setting up": the newest snapshot had no ammonia in it, and
the code took that as the cycle going backwards. Stall detection had the other
half of the bug. KH and pH are often tested on a different day from ammonia,
so each check now uses the latest session that measured that particular thing.</p>
<h2>Free ammonia from pH and temperature</h2>
<p>Kits report total ammonia. The seriously toxic part is un-ionised NH₃, and its
share depends on pH and temperature, as the
<a href="https://www.epa.gov/caddis/ammonia">EPA's ammonia page</a> explains. When the
user has typed all three into the same session, the rest is arithmetic.</p>
<pre><code class="language-dart">/// pKa = 0.09018 + 2729.92 / T(K). At 25 C this gives ~9.25.
static double pKa(double temperatureC) =&gt;
    0.09018 + 2729.92 / (temperatureC + 273.15);

/// Fraction of total ammonia present as toxic, un-ionised NH3 (0..1).
static double freeFraction({required double ph, required double temperatureC}) =&gt;
    1 / (1 + math.pow(10, pKa(temperatureC) - ph));

static double freeAmmoniaPpm({
  required double totalAmmoniaPpm,
  required double ph,
  required double temperatureC,
}) =&gt; totalAmmoniaPpm * freeFraction(ph: ph, temperatureC: temperatureC);
</code></pre>
<p>The pKa expression is the same as in the Florida Department of Environmental
Protection's
<a href="https://floridadep.gov/sites/default/files/unnh3sop.pdf">procedure for calculating un-ionized ammonia</a>,
which writes the constant as 0.0901821, credits Thurston et al., and gives it
as valid from 0 to 50 °C and pH 6 to 10. At 25 °C it puts about 0.56% of total
ammonia in the free form at pH 7 and 5.4% at pH 8, matching the table in that
document. One test pins down why
the feature exists: 0.5 ppm total ammonia is 0.0017 ppm free at pH 6.8 and
24 °C, and 0.054 ppm at pH 8.3 and 26 °C. Same kit reading, about 33 times
apart.</p>
<p>The result appears only when the saved session has ammonia, pH and temperature
together; it doesn't borrow last week's pH. The known limitation: the
expression is for fresh water. The same document says the free fraction drops
slightly as ionic strength rises and gives a separate correction for saline
water, which the app doesn't apply, so in a reef tank it errs high. It
calculates; it doesn't diagnose.</p>
<h2>One stored unit per parameter</h2>
<p>Each reading keeps the value in the parameter's canonical unit and, beside it,
exactly what the user typed and in which unit, so a conversion bug can't
destroy the original observation. Nitrate is stored as the NO₃ ion, alkalinity
in meq/L, salinity in ppt, temperature in °C. The display unit is a device
setting, since it's about how this person reads their kit, not a fact about
the tank. Grading uses the canonical value, so a reading in range stays in
range whatever it's shown as.</p>
<p>The ratios are where silent errors live. NO₃ is 4.427 times NO₃-N, so the same
water reads 10 on one kit and 44 on another. Nitrite has its own ratio, 3.284.
One meq/L is 2.8 dKH. An unknown unit throws instead of passing the number
through, because an unconverted alkalinity is off by 2.8 times and still looks
plausible on a chart. I caught myself quoting nitrate's 4.4 on the nitrite row,
on a screen whose whole job is preventing unit mistakes. Both figures now come from
constants pinned by tests.</p>
<h2>Drift as the source of truth, with an outbox</h2>
<p>The UI reads only from Drift. Every write goes into its table and into an
outbox table in the same transaction, so nothing appears on screen without
also being queued for the server.</p>
<pre><code class="language-dart">await _db.transaction(() async {
  // ...the session row and its outbox entry first, then for each reading:
  await _db.into(_db.readings).insert(ReadingsCompanion.insert(
    id: id,
    tankId: draft.tankId,
    sessionId: Value(sessionId),
    parameterKey: entry.parameterKey,
    valueCanonical: canonical,
    enteredValue: entry.value, enteredUnit: entry.unit,
    measuredAt: draft.measuredAt,
    updatedAt: stamp,
  ));
  await _outbox.enqueue(entity: SyncEntity.readings, entityId: id, op: SyncOp.upsert,
      payload: {/* the same row as JSON */});
});
</code></pre>
<p>The outbox stores the payload rather than re-reading the row later, so an
edit can't change what an earlier queued write meant. IDs are UUID v7 made on
the device, so a reading can reference a tank that hasn't synced yet. Deletes
set <code>deleted_at</code>, because a delta sync can't express a row that stopped
existing.</p>
<p>The sync engine is written against a small <code>SyncRemote</code> interface and tested
against a fake, on an in-memory SQLite database rather than a mock. It pushes
before it pulls, parents before children. The pull asks for rows with
<code>updated_at</code> after the stored cursor minus two seconds, because a transaction
that began before the cursor can commit after it. The cursor comes from server
rows, stamped by a Postgres trigger, never from the device clock, and a pulled
row is skipped while the same row has an unpushed local edit. Drift
<a href="https://drift.simonbinder.eu/guides/datetime-migrations/">reads DateTimes back in local time</a>
by default, so I set <code>store_date_time_values_as_text</code>, which keeps UTC as UTC.</p>
<p>What went wrong:</p>
<ul>
<li>Against a project that took fourteen seconds to answer auth, eight changes
were dead-lettered and shown as refused by the server. They hadn't been.
Each timeout counted as an attempt across launches and resumes, and after
five the tank was abandoned; its parameter settings then pointed at a tank
the server had never seen, row level security refused them, and they went
too. Now being offline never uses up attempts, a pass stops before anything
depending on an abandoned row, and dead letters have a "Try again".</li>
<li>Sync only ran while the account screen was open, because only that screen
watched the provider. It's now watched at the app root and invalidated on
resume.</li>
<li>The tank screen kept showing "not tested yet" after a test was logged,
until a restart. The query watched only the settings table but attached the
last reading from another one; Drift's
<a href="https://drift.simonbinder.eu/sql_api/custom_queries/"><code>readsFrom</code></a> fixed
it. The tests missed it because they called <code>.first</code> after writing. The new
one subscribes first.</li>
</ul>
<h2>Row level security, briefly</h2>
<p>Supabase only holds a copy for people who sign in, and row level security is
the real boundary; the publishable key in the app authorises nothing on its
own. Tanks carry an <code>owner_id</code>, and a <code>tank_members</code> table that every policy
already reads through means sharing can come later without a migration. Every
child table carries <code>tank_id</code>, so one pair of policies covers all of them:</p>
<pre><code class="language-sql">do $$
declare t text;
begin
  foreach t in array array[
    'tank_parameter_config','test_sessions','readings','livestock',
    'task_templates','task_events','journal_entries','photos','cycle_logs'
  ] loop
    execute format($f$
      create policy %1$s_select on public.%1$s
      for select to authenticated
      using (tank_id = any (array(select private.accessible_tank_ids())))
    $f$, t);
    -- plus a matching %1$s_write policy using private.can_edit_tank(tank_id)
  end loop;
end $$;
</code></pre>
<p>The helpers are <code>security definer</code> functions in a <code>private</code> schema with an
empty <code>search_path</code>, revoked from the API roles, which also breaks the
infinite recursion of a <code>tank_members</code> policy that queries <code>tank_members</code>.
<code>auth.uid()</code> is wrapped as <code>(select auth.uid())</code>, as the
<a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase RLS docs</a>
recommend. A pgTAP suite simulates two users by setting <code>request.jwt.claims</code>,
and an integration test runs the real sync code against a local Supabase stack
and checks that a second account sees none of it.</p>
<p>What the app does for the people who use it: <a href="https://treamz.me/blog/aquarium-water-test-log-app">A free aquarium water test log app for iPhone</a>.</p>
<h2>FAQ</h2>
<h3>How do you calculate free ammonia from pH and temperature?</h3>
<p>Compute pKa = 0.09018 + 2729.92 / T with T in kelvin, then the free fraction
= 1 / (1 + 10^(pKa − pH)), and multiply total ammonia by it. At 25 °C pKa is
about 9.25, so roughly 0.56% of total ammonia is un-ionised NH₃ at pH 7 and
5.4% at pH 8. The expression is for fresh water; in seawater the real fraction
is slightly lower.</p>
<h3>Should an untested value be stored as null or zero?</h3>
<p>Neither. In Aquarium an untested parameter produces no reading row, and nulls
appear only when rows are grouped into per-session views. Storing zero for
"not tested" draws dips that never happened and makes rate-of-change checks
compare against a value nobody measured.</p>
<h3>How do you build offline-first sync with Drift and Supabase?</h3>
<p>Make Drift the only thing the UI reads, and write every change to its table
and to an outbox table in one Drift transaction. A sync engine pushes the
outbox parents-first, then pulls rows whose server-stamped <code>updated_at</code> is
newer than the last one it saw minus a short overlap, skipping rows with
unpushed local edits. Client-generated UUIDs and <code>deleted_at</code> tombstones let
both sides agree on what changed.</p>
<h3>How does an app decide an aquarium is cycled?</h3>
<p>Aquarium groups readings into test sessions and looks for two sessions in a
row where ammonia and nitrite are at or below 0.25 ppm and nitrate is present.
One such session is shown as "confirming", because a single clean result can
be a misread.</p>
<h3>How do you write Supabase RLS policies for tables that belong to a parent row?</h3>
<p>Give every child table the parent's id, put the access check in a
<code>security definer</code> function in a schema the API can't reach, and generate the
same select and write policy for each table in a loop. Wrap <code>auth.uid()</code> as
<code>(select auth.uid())</code> and index the columns the policies filter on.</p>
]]></content:encoded></item><item><title><![CDATA[Mapping free street parking from open data with PostGIS and MapKit]]></title><description><![CDATA[Turning cities' paid-parking data into likely-free kerbs with PostGIS, why OSM parking tags fail outside Germany, and drawing them in SwiftUI Map.]]></description><link>https://treamz.hashnode.dev/mapping-free-street-parking-from-open-data-with-postgis-and-mapkit</link><guid isPermaLink="true">https://treamz.hashnode.dev/mapping-free-street-parking-from-open-data-with-postgis-and-mapkit</guid><category><![CDATA[open data]]></category><category><![CDATA[PostGIS]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Swift]]></category><dc:creator><![CDATA[Ivan Chernoknizhnikov]]></dc:creator><pubDate>Thu, 12 Nov 2026 17:31:46 GMT</pubDate><content:encoded><![CDATA[<p>This post covers why OpenStreetMap gives kerb-level parking in only some
cities, how I turn a council's paid streets into "likely free" kerbs with
PostGIS, how that guess stays apart from real facts, and how SwiftUI's <code>Map</code>
draws a city's kerbs without stuttering. The app is Easy Find Parking, a free
iPhone and iPad map of free street parking in Berlin, Madrid, Barcelona,
València and Kraków,
<a href="https://apps.apple.com/us/app/easy-find-parking-europe/id6502238318">on the App Store</a>;
the user's side is in <a href="https://treamz.me/blog/free-parking-app-europe">a separate post</a>.</p>
<p>Server: Flask, PostGIS and a self-hosted ParkAPI. Phone: SwiftUI on iOS 17.</p>
<h2>OpenStreetMap street parking is mostly a German dataset</h2>
<p>My first plan was OpenStreetMap everywhere. Kerbside parking is tagged on the
street itself, with keys like <code>parking:left=lane</code> and <code>parking:right:fee=no</code>
(see <a href="https://wiki.openstreetmap.org/wiki/Street_parking">Street parking</a> on
the OSM wiki). When I measured in September 2026, this was the share of
drivable streets carrying any <code>parking:*</code> tag:</p>
<table>
<thead>
<tr>
<th>City</th>
<th align="right">Streets with <code>parking:*</code> tags</th>
</tr>
</thead>
<tbody><tr>
<td>Berlin</td>
<td align="right">99.9%</td>
</tr>
<tr>
<td>Hamburg</td>
<td align="right">54%</td>
</tr>
<tr>
<td>Warsaw</td>
<td align="right">26%</td>
</tr>
<tr>
<td>Lyon</td>
<td align="right">22%</td>
</tr>
<tr>
<td>Milan</td>
<td align="right">11%</td>
</tr>
<tr>
<td>Paris</td>
<td align="right">10%</td>
</tr>
<tr>
<td>València</td>
<td align="right">7%</td>
</tr>
<tr>
<td>Barcelona</td>
<td align="right">4%</td>
</tr>
<tr>
<td>Lisbon</td>
<td align="right">0.9%</td>
</tr>
</tbody></table>
<p>Fee sub-tags were on about a tenth of those. Berlin works: 64,229 kerb sides and
48,567 car parks from one Geofabrik extract. Elsewhere a tag-driven layer would
be mostly blank, which reads as "no parking here". So outside Germany the
street layer comes from municipal open data, which the city actually enforces.</p>
<p>Reading the tags has traps. The wiki deprecated the older <code>parking:lane=*</code> and
<code>parking:condition=*</code> keys in December 2022 and asks for existing ones to be
converted, so the normaliser reads both. <code>parking:left=no</code> is a value, not an
absence: 43% of Berlin's tagged sides. And without <code>closed_ways_are_polygons</code>
in its config, GDAL put closed <code>amenity=parking</code> ways in the lines layer, where
my highway filter dropped them: Berlin had 42 car-park polygons instead of
63,294.</p>
<h2>Deriving "likely free" from what a city charges for</h2>
<p>Councils publish what they charge for, because that's what they enforce.
Barcelona's
<a href="https://opendata-ajuntament.barcelona.cat/data/dataset/trams-aparcament-superficie">surface parking dataset</a>
lists its blue, green and goods-delivery segments; Kraków publishes 23 paid-zone
polygons and no kerbs. The complement is the free map: every drivable street
inside the city boundary with nothing regulated near it.</p>
<pre><code class="language-sql">CREATE TEMP TABLE regulated AS
SELECT geom FROM efp.street_parking
WHERE city_id = :'city' AND fee_source IN ('municipal_paid', 'municipal_free')
UNION ALL
SELECT geom::geography FROM efp.paid_zone WHERE city_id = :'city';  -- Kraków's polygons

-- ...
FROM osm_stage.streets_REGIONTAG s
CROSS JOIN (VALUES ('left'), ('right')) AS sides(side)
WHERE EXISTS (SELECT 1 FROM city_boundary b WHERE ST_Within(s.geom, b.geom))
  AND NOT EXISTS (
      SELECT 1 FROM regulated r
      WHERE ST_DWithin(efp.kerb_offset(s.geom, sides.side)::geography, r.geom, :clearance))
  AND s.highway IN ('residential', 'living_street', 'unclassified', 'tertiary', 'tertiary_link')
  AND lower(coalesce(s.access, '')) NOT IN ('private', 'no', 'customers')
</code></pre>
<p>Three of these are there because a looser version published something wrong.</p>
<p><strong>Metres, not degrees.</strong> The first version buffered by 0.00025°. At
Barcelona's latitude that's 27.8 m north–south but 20.8 m east–west, and 407
streets 21–25 m from a paid kerb went out as free. Casting to <code>geography</code> makes
<code>ST_DWithin</code> measure in metres; <code>:clearance</code> is 25.</p>
<p><strong>Test the kerb, not the centreline.</strong> <code>efp.kerb_offset</code> shifts the street 3 m
to one side, working in EPSG:3857 and dividing the distance by cos(latitude) to
undo Mercator's stretch. That line can sit closer to a paid kerb than the
centreline, so it's the one tested.</p>
<p><strong>Leave out roads with no parking.</strong> The first run turned 17,275 regulated
segments into 10,842 free kerb sides, and the map showed dashed "likely free"
lines down Avinguda Diagonal. Arterials have no regulated segments because
nobody parks on them, so "outside every paid zone" came out true. Limiting the
rule to residential, unclassified and tertiary roads dropped 3,156 sides, 29%
of the layer. Service roads stay out: they're car-park aisles and driveways as
often as public kerb.</p>
<p>Kraków's polygons feed the same rule: 21,255 free kerb sides, none within 25 m
of a zone. A test samples 2,000 derived kerbs per city to assert
it. Checking every row took over 150 seconds at 29,000 derived against
105,000 regulated, and a check nobody waits for is a check nobody runs.</p>
<h2>Free and likely free are different values</h2>
<p>The schema never stores a bare "free". Every row has a <code>fee_state</code> (<code>FREE</code>,
<code>PAID</code>, <code>CONDITIONAL</code>, <code>UNKNOWN</code>) and a <code>fee_source</code> saying where it came from:
<code>municipal_free</code>, <code>osm_tag</code>, <code>operator_feed</code>, <code>municipal_paid_complement</code> and
<code>municipal_outside_zone</code>.</p>
<p>The last one came from Madrid, whose SER map service publishes 87,614 kerb
bands. 53,158 are grey, a colour missing from the service's own legend.
Sampling 300 bands of each colour against the SER boundary settled it: every
blue and green band tested fell inside, no grey one did. So grey bands are
published kerbs outside the paid zone: stronger than an inference from nothing,
weaker than the council saying "free". I first stored them as
<code>municipal_paid_complement</code>, and the overlap test failed, correctly: grey bands
sit next to blue ones on the same street, which for a derived street would be a
bug.</p>
<p>One function turns the pair into a claim, ordered by trust: a current count, a
published fact, a schedule, an inference, an admitted gap.</p>
<pre><code class="language-swift">switch (feeState, feeSource) {
case (.free, .municipalFree), (.free, .operatorFeed):
    return .cityFree
case (.free, .osmTag):
    return .signpostedFree
case (.free, .municipalOutsideZone), (.free, .municipalPaidComplement):
    // This is the inference, and it must never borrow a stronger phrasing.
    return .likelyFree
case (.free, _):
    // A free claim with no provenance is not one we will repeat.
    return .unknown
default:
    break
}
</code></pre>
<p>Charging hours the parser can't read resolve to "charged right now": wrong
that way costs a walk, the other way a fine.</p>
<p>On the first build the inferred layer was mint and read as the same free as
the confirmed green. Now it's teal and dashed, which says "not solid" before
anyone reads the legend:</p>
<pre><code class="language-swift">ForEach(model.streets) { street in
    MapPolyline(coordinates: street.coordinates)
        .stroke(street.claim.tint.opacity(0.85),
                style: street.claim.isInferred ? .inferredKerb : .kerb)
}

private extension StrokeStyle {
    static let kerb = StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)
    /// Dashed: derived from an absence of regulation, not from a sign.
    static let inferredKerb = StrokeStyle(
        lineWidth: 4, lineCap: .butt, lineJoin: .round, dash: [3, 7])
}
</code></pre>
<p>Both hedged kinds draw the same dashed teal; the data keeps them apart.</p>
<h2>Drawing kerbs with SwiftUI's Map</h2>
<p>That <code>ForEach</code> is the whole renderer, and its cost is the count: a city-wide
Barcelona viewport holds around 660 kerbs. The obvious fix is one
<code>MKMultiPolyline</code> per style, about four overlays, but <code>MapPolyline</code> has no
initialiser that takes one. So I rewrote the map as <code>MKMapView</code> in a
<code>UIViewRepresentable</code>. It worked in the simulator and drew no tiles on a real
device. I couldn't reproduce it, and a blank map is worse than a slow one, so I
went back to SwiftUI's <code>Map</code>. What keeps it smooth:</p>
<ul>
<li><strong>Measure first.</strong> The database answered in 7.6 ms and the endpoint in under
half a second, so the stutter was on the phone.</li>
<li><strong>Fetch a ring, draw the screen.</strong> The app fetches 30% of the span beyond
each edge, skips the request while the viewport stays inside it, and waits
250 ms for the camera to settle. Drawing is clipped to the viewport, about
60% fewer lines, and the array is reassigned only when visible IDs change:
an identical array still makes SwiftUI diff every overlay.</li>
<li><strong>Slim payload.</strong> Street name, zone, capacity and source were 65% of the
bytes and none is drawn, so the layer gets four fields per kerb and
coordinates rounded to six decimals, about 10 cm. A city-wide response went
from 575 KB to 359 KB.</li>
<li><strong>Nearest first.</strong> The query orders by <code>sp.geom &lt;-&gt; centre</code> with a limit of
1,500, so truncation eats the ring, not the middle of the screen.</li>
</ul>
<p>One feature from the live API:</p>
<pre><code class="language-json">{
  "type": "Feature",
  "geometry": {
    "type": "LineString",
    "coordinates": [[2.174812, 41.403032], [2.174847, 41.403005], [2.174876, 41.402983]]
  },
  "properties": {
    "source_id": "complement-es-barcelona-885702924-left",
    "fee_state": "FREE",
    "fee_source": "municipal_paid_complement",
    "opening_hours": null
  }
}
</code></pre>
<p>A cap of 400 drawn kerbs left the edges of a Barcelona screen bare, and a
driver can't tell a capped edge from a street with no parking, so it went.</p>
<p>València publishes individual spaces, 315,303 averaging 2.6 m, so 1,500
features covered a few blocks. <code>ST_ClusterDBSCAN</code>, partitioned by street, fee
state and a grid of about 300 m, now merges adjacent bays into segments with a
bay count: the same view went from 1,500 features and 453 KB to 582 and 135 KB.
Without the grid, nameless paid bays chained into lines 25 km long. A polyline
threaded through the bays zig-zagged across the street, since bays sit on both
sides, so each cluster is drawn as the longest line across its convex hull.</p>
<p><img src="https://treamz.me/blog/free-parking-app-europe/free-kerbs-valencia.jpg" alt="The map in València at street level, with free kerbs drawn as solid green lines" title="València: each green line is a run of free bays merged into one segment" /></p>
<p>Zoomed out past 0.02° of span, the kerb layer switches off and the gateway
counts kerbs in roughly an 8×8 grid over the viewport, drawn as circles.
These aren't MapKit annotation clusters: the phone never downloads the kerbs at
that zoom. Circle size is logarithmic, because counts span four orders of
magnitude and linear sizing pinned nearly every circle to the maximum.</p>
<h2>Live counts go stale after 15 minutes</h2>
<p>The old app read <code>api.parkendd.de</code>, which had stopped updating. Now I run my
own <a href="https://github.com/ParkenDD/park-api-v3">ParkAPI v3</a> (MIT), pulling
realtime data every 300 seconds into the same Postgres as my schema, so the
gateway joins its <code>parking_site</code> table directly. The gateway decides freshness,
so phone and server can't disagree:</p>
<pre><code class="language-python">STALE_AFTER = timedelta(minutes=15)

def freshness(updated_at: datetime | None) -&gt; str:
    if updated_at is None:
        return 'none'
    age = datetime.now(timezone.utc) - updated_at
    return 'live' if age &lt;= STALE_AFTER else 'stale'

for row in rows:
    row['freshness'] = freshness(row.get('realtime_updated_at'))
    # Keep the number, drop the claim that it is current.
    row['realtime_is_current'] = row['freshness'] == 'live'
</code></pre>
<p>ParkAPI's own public API drops realtime fields once they're too old. I keep the
number and label it: a stale count shows grey as "N free when last seen", with
its age. On 9 November 2026, 15 car parks within 2 km of central Basel had a
current count.</p>
<p>Indigo's feed reported zero occupancy for all 558 French car parks, which naive
arithmetic shows as empty, so realtime is off for it. And 28% of upstream
ParkAPI converters were already dead when I set it up, so each source's last
success and error live in a table.</p>
<h2>Licences and gaps</h2>
<p>OpenStreetMap data is ODbL (© OpenStreetMap contributors). Barcelona and
València publish under CC BY 4.0, Madrid and Kraków under their own open-data
terms, the French national base and Indigo under ODbL. Derived layers credit
both the council and OSM, and the app shows these credits behind its info
button.</p>
<p>Missing: an offline cache, address search, and Warsaw. Its city API timed out
when I checked, and OSM had fee information on 0% of its central streets. Kerb
lines show rules, not empty spaces.</p>
<p>What the app does for the people who use it: <a href="https://treamz.me/blog/free-parking-app-europe">Free parking app for Europe: Easy Find Parking for iPhone</a>.</p>
<h2>FAQ</h2>
<h3>How complete is OpenStreetMap parking data in European cities?</h3>
<p>Very uneven. When I measured in September 2026, the share of drivable streets
with any <code>parking:*</code> tag was 99.9% in Berlin, 10% in Paris, 4% in Barcelona
and 0.9% in Lisbon, and about a tenth of tagged streets had fee
information. Outside Germany, municipal open data is usually the better source.</p>
<h3>How do you find free street parking from a city's paid parking data?</h3>
<p>Take the OpenStreetMap road network inside the city boundary and drop any
street side within a clearance (25 m in Easy Find Parking) of a published paid
segment or zone polygon. Limit it to residential, unclassified and tertiary
roads, or arterials come out free, and label the result "likely free".</p>
<h3>Should I buffer in degrees or metres in PostGIS?</h3>
<p>Metres. On EPSG:4326 geometry, distances are in degrees, and a degree of
longitude shrinks with latitude: 0.00025° is 27.8 m north–south but 20.8 m
east–west in Barcelona. Cast to <code>geography</code> and <code>ST_DWithin</code> takes metres.</p>
<h3>How do I draw thousands of polylines in a SwiftUI Map without lag?</h3>
<p>SwiftUI's <code>Map</code> needs one <code>MapPolyline</code> per line and can't take an
<code>MKMultiPolyline</code>, so the line count is the cost. Fetch a padded area, skip
requests while the viewport stays inside it, debounce camera changes by about
250 ms, draw only what's visible and send only the fields you draw. Zoomed out,
show area counts instead.</p>
<h3>How do you know if a live car park count is still current?</h3>
<p>Compare its timestamp with the server's clock. Easy Find Parking treats a count
older than 15 minutes as stale: it keeps the number but shows it as "free when
last seen" with its age, never as spaces available now.</p>
]]></content:encoded></item><item><title><![CDATA[Identifying card printings on device with Vision and OCR]]></title><description><![CDATA[Artwork matching picked the right card printing 89.5% of the time even given the number. Combining Vision feature prints with OCR, and when to ask.]]></description><link>https://treamz.hashnode.dev/identifying-card-printings-on-device-with-vision-and-ocr</link><guid isPermaLink="true">https://treamz.hashnode.dev/identifying-card-printings-on-device-with-vision-and-ocr</guid><category><![CDATA[iOS]]></category><category><![CDATA[Swift]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[Machine Learning]]></category><dc:creator><![CDATA[Ivan Chernoknizhnikov]]></dc:creator><pubDate>Thu, 12 Nov 2026 17:29:23 GMT</pubDate><content:encoded><![CDATA[<p>How the scanner in Foilcase works out which printing of a trading card it is looking at: Vision feature prints for the artwork, text recognition for the collector number, and a strict rule for when to ask instead of guessing. Plus the storage underneath it: an append-only ledger and one daily price file for everyone. Foilcase is my iPhone app for scanning and pricing cards from five trading card games (<a href="https://apps.apple.com/us/app/foilcase-tcg-card-scanner/id6813665558">App Store</a>); the <a href="https://treamz.me/blog/trading-card-scanner-app">user-facing write-up</a> covers what it does.</p>
<h2>Why artwork matching picks the wrong print</h2>
<p>Before release I built a harness on a Mac that calls the same Vision APIs as the app: 9,903 cards indexed, 400 queries, half drawn on purpose from collector numbers that more than one print shares. Each query was a synthetic degradation of an official card image (perspective, blur, glare, colour cast, JPEG).</p>
<table>
<thead>
<tr>
<th>Sharp frame, 400 queries</th>
<th>Correct</th>
</tr>
</thead>
<tbody><tr>
<td>Artwork, top-1</td>
<td>87.8%</td>
</tr>
<tr>
<td>Artwork, top-1 on shared numbers</td>
<td>78.5%</td>
</tr>
<tr>
<td>Artwork, top-5</td>
<td>99.8%</td>
</tr>
<tr>
<td>Artwork, top-1 when given the correct number</td>
<td>89.5%</td>
</tr>
</tbody></table>
<p>The last row changed the design. Even with the right collector number, artwork matching picks the right print 89.5% of the time, because alternate arts and higher-rarity parallels often share one number and one illustration. No embedding separates two prints of one picture, so the app asks which print, as it asks whether a card is foil. And since five candidates nearly always include the right card, the five-card sheet is the main flow, not an error state.</p>
<p>The embedding is <a href="https://developer.apple.com/documentation/vision/vngenerateimagefeatureprintrequest"><code>VNGenerateImageFeaturePrintRequest</code></a>, revision 2: 768 floats, about 9 ms an image. One option matters more than it looks:</p>
<pre><code class="language-swift">static func featurePrint(of image: CIImage) -&gt; [Float]? {
    let request = VNGenerateImageFeaturePrintRequest()
    request.imageCropAndScaleOption = .scaleFill
    try? VNImageRequestHandler(ciImage: image, options: [:]).perform([request])
    guard let observation = request.results?.first else { return nil }
    var vector = [Float](repeating: 0, count: observation.elementCount)
    _ = vector.withUnsafeMutableBytes { observation.data.copyBytes(to: $0) }
    return vector
}

static let artworkMatchingAvailable: Bool = {
    let probe = CIImage(color: .gray).cropped(to: CGRect(x: 0, y: 0, width: 64, height: 90))
    return featurePrint(of: probe) != nil
}()
</code></pre>
<p>The default, <code>.centerCrop</code>, cuts the top and bottom off a tall card: 82.4% against 93.2% for <a href="https://developer.apple.com/documentation/vision/vnimagecropandscaleoption/scalefill"><code>.scaleFill</code></a> on the same 1,295-card index. The probe exists because feature prints fail in the Simulator ("Failed to create espresso context"). The app checks once and falls back to the collector number alone, as it would on any device without a usable neural engine.</p>
<h2>Distance is not a confidence score</h2>
<p>The obvious next step is a distance threshold. Measured, it says almost nothing: median distance was 0.450 when the top match was right and 0.420 when it was wrong. But distance does tell whether there is a card at all: photographed cards sat at a median of 0.459, a phone pointed at a laptop at 0.966. I found that out when the scanner, with no ceiling, turned a browser window into twenty-six confident entries. The ceiling is now 0.80, which keeps 99% of photographed cards and rejects 89% of everything else.</p>
<h2>Reading the collector number</h2>
<p>The number is unambiguous whenever it is legible, so most of the work went into legibility.</p>
<p><strong>Drop soft frames.</strong> Between a handheld frame and a sharp one, the artwork gained 5.6 points and the collector number 46 (32.8% read against 78.5%). The gate is the variance of the Laplacian on a greyscale buffer of the rectified card. My first version chained Core Image filters, a Laplacian then a multiply blend, and returned negative variances: blend modes clamp and premultiply, they are not arithmetic.</p>
<p><strong>Don't ask Vision to find the card.</strong> On iOS, <code>VNDetectDocumentSegmentationRequest</code> returned the card's text box at 0.96 confidence, and <code>VNDetectRectanglesRequest</code> found artwork panels, never the border. So the scanner draws a card-shaped guide, tries three crops around it and keeps whichever embeds closest to the index.</p>
<p><strong>Read in passes, at full size.</strong> Downscaling crops is how my first benchmark reported an OCR accuracy of zero. <a href="https://developer.apple.com/documentation/vision/vnrecognizetextrequest"><code>VNRecognizeTextRequest</code></a> runs over the whole card, then the top and bottom 22% enlarged 2.5 times, then three corners at 4 times if nothing turned up:</p>
<pre><code class="language-swift">private func scan(_ image: CIImage, into found: inout Set&lt;String&gt;, minimumTextHeight: Float = 0.008) {
    let request = VNRecognizeTextRequest()
    request.recognitionLevel = .accurate
    request.usesLanguageCorrection = false   // these are codes, not words
    request.recognitionLanguages = ["en-US"]
    request.minimumTextHeight = minimumTextHeight   // the default floor skips the number
    try? VNImageRequestHandler(ciImage: image, options: [:]).perform([request])

    for observation in request.results ?? [] {
        for candidate in observation.topCandidates(2) {
            found.formUnion(PrintedNumber.extract(from: candidate.string))
        }
    }
}
</code></pre>
<p>The recognizer swaps look-alikes (S and 5, O and 0, B and 8), so readings are folded before comparing. That tolerance is what kept producing wrong additions. The fix was two keys: a loose one, padding removed, for <em>finding</em> candidates, and a literal one, padding and variant marks kept, for <em>adding</em> without asking. "45/195" fits a catalog "045/195", but it is also what "145/195" looks like with its first digit lost.</p>
<h2>When the scanner adds and when it asks</h2>
<p>Add without asking only when the artwork's first choice (or a card it can't tell apart from it) and a reading that spells that card's number literally name the same card, and nothing the artwork can't rule out shares that number. Everything else is the sheet. Trimmed from <code>ScanPipeline.decide</code>:</p>
<pre><code class="language-swift">func blocks(_ card: CardRef) -&gt; Bool {
    guard card.id != winner.id else { return false }
    guard baseName(card) != baseName(winner.card) else { return true }   // same card, other print
    guard let margin = policy.distinctParallelMargin, let mine = winner.distance,
          let theirs = distances[card.id] else { return true }
    return theirs &lt; mine * (1 + margin)
}
if parallels.contains(where: blocks) { return .ambiguous(candidates: offered, reason: .parallelPrints, numbers: numbers) }
let artworkAgrees = winner.id == candidates[0].id
    || artworkTies(candidates[0], winner, policy.minimumRelativeMargin)
let byNumber = winner.numberMatched &amp;&amp; artworkAgrees
    &amp;&amp; (spelled(winner.card, literals) || unpadded)
guard byNumber else {
    return .ambiguous(candidates: ordered, reason: winner.numberMatched ? .numberDisagrees : .lowConfidence, numbers: numbers)
}
return .identified(winner)
</code></pre>
<p>A different card sharing the number stops blocking only if the artwork puts it at least 15% farther away. <code>unpadded</code> lets an old card printed "18/130" match a catalog "018/130", but only when it lands on the artwork's own first pick.</p>
<p>The rule took three rounds of adversarial review to reach, each reported bug reproduced against the real catalogs. The first confirmed 33 defects; the second found 12 new ones, 11 introduced by the fixes. As my commit message put it, a comparison built to <em>find</em> candidates was also allowed to <em>add</em> one. Adding on artwork margin alone went too: a card missing from the index still has a nearest neighbour that can be clearly ahead and wrong.</p>
<p>Before a silent add, a second text request also counts Han, kana and hangul characters, because artwork and number are identical across languages and each catalog holds one. English cards read 0 to 3, Japanese ones 37 to 188; the cut-off is 8.</p>
<p>Then I measured on 529 real photographs of cards, labelled against the catalog, with all five games installed (62,331 cards in one index) and the app's recognition code running on a Mac. Every silent add was checked by eye:</p>
<table>
<thead>
<tr>
<th>529 real photos</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Added without asking, wrong card</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>Added without asking, right card</td>
<td>21%</td>
<td>42%</td>
</tr>
<tr>
<td>Right card added, or first in the sheet</td>
<td>69%</td>
<td>90%</td>
</tr>
<tr>
<td>Collector number read</td>
<td>59%</td>
<td>77%</td>
</tr>
</tbody></table>
<p>The gains came from the photos: a picked photo now skips the quality gates, since no better frame is coming; the number is read from the loosest crop when the tightest cut it off; and each crop is also embedded slightly blurred, because a holo's sparkle pulled matches away from the flat scans in the index (right card first: 377 to 397). The live camera path is still unmeasured; for that, the app keeps an on-device calibration log of each attempt and the card the user actually picked.</p>
<h2>Keeping a 27,000-card index on the phone</h2>
<p>Each game's index downloads separately, quantised to int8 with one scale per pack. On queries perturbed to the distance a handheld photo sits at, int8 and float16 returned the same card 99.8% of the time, for half the bytes. There's no approximate nearest-neighbour library: in the Mac harness, a brute-force scan of the largest index, 26,978 vectors, takes 1.6 ms widened to <code>Float</code>, but holds 83 MB. So the app memory-maps the int8 bytes and converts 512 rows at a time with <a href="https://developer.apple.com/documentation/accelerate/vdsp_vflt8"><code>vDSP_vflt8</code></a> and <a href="https://developer.apple.com/documentation/accelerate/vdsp_distancesq"><code>vDSP_distancesq</code></a>: 5.6 ms, nothing resident. The scan runs only on a steady frame, so four milliseconds is invisible and 83 MB isn't. A pack from a different Vision revision is refused; its distances live in another space.</p>
<p>The index is built on a Mac and lags the catalog, so a clearly read number naming a card the artwork never offered goes to the top of the sheet, asked about and never added.</p>
<h2>A ledger that never overwrites, and one price file for everyone</h2>
<p>The collection is an append-only <code>ledger</code> table plus an <code>items</code> table derived from it in the same transaction. Removal is a tombstone; <code>rebuild()</code> replays the log. Replay needs an order every device agrees on, and the middle term of the sort key is a bug fix: the scanner adds a card and sets its finish within one millisecond, and a random event id sometimes sorted the edit first.</p>
<pre><code class="language-swift">/// Creation sorts before every edit to the same item at the same instant.
var replayRank: Int {
    if case .added = self { return 0 }
    return 1
}

for record in records.sorted(by: { ($0.timestamp, $0.event.replayRank, $0.eventID)
                                 &lt; ($1.timestamp, $1.event.replayRank, $1.eventID) }) {
</code></pre>
<p>It paid off in October. The upstream catalog renamed sets, card ids include the set name, and 1,323 cards in one game got new ids overnight, 222 more across the others. Copies saved under old ids dropped off the collection screen. The fix is an alias table read in memory; the log stays as written.</p>
<p>Prices are one binary file per game, the same bytes for every user, so a request says nothing about what anyone owns. Names are content-addressed and the manifest is written last, so a client never sees half an update. Prices are integer cents, with a sentinel so a $0.00 promo isn't an unpriced card. Each carries the day it was captured, and past <code>PriceProvenance.staleAfterDays</code> (7) the portfolio headline says "These prices are 2 weeks old" instead of presenting an old total as current.</p>
<p>What the app does for the people who use it: <a href="https://treamz.me/blog/trading-card-scanner-app">Scan trading cards and see what they're worth with Foilcase</a>.</p>
<h2>FAQ</h2>
<h3>How do you tell apart card printings that share the same artwork?</h3>
<p>Mostly not from the image: in my pre-release harness, artwork matching picked the right print 89.5% of the time even when given the correct collector number. Foilcase reads the number with Vision text recognition and, when other prints share it, shows the candidates and asks.</p>
<h3>Is VNGenerateImageFeaturePrintRequest good enough for on-device image recognition?</h3>
<p>For retrieval, yes, with no model to ship: the right card was in the top five 99.8% of the time on sharp synthetic frames of a 9,903-card index. Use <code>.scaleFill</code>, and test on a device, because it doesn't run in the Simulator.</p>
<h3>Do I need an approximate nearest neighbour library for on-device image search?</h3>
<p>Not at tens of thousands of vectors. A brute-force Accelerate scan over 26,978 feature prints took 1.6 ms on a Mac in Float, or 5.6 ms as memory-mapped int8, which avoids holding 83 MB in memory.</p>
<h3>Can embedding distance be used as a confidence score?</h3>
<p>Not between similar images: median distance was 0.450 for correct matches and 0.420 for wrong ones. It does separate cards (0.459) from non-cards (0.966), so Foilcase uses it only as a 0.80 ceiling.</p>
<h3>How do you read small printed numbers with VNRecognizeTextRequest?</h3>
<p>Lower <code>minimumTextHeight</code>, turn off language correction, never downscale the crop, and add passes over enlarged strips and corners. Rejecting blurry frames first matters most: it took collector-number accuracy from 32.8% to 78.5% in my harness.</p>
<h3>Why store a collection as an append-only ledger?</h3>
<p>Nothing is overwritten, so a mis-tap or a bug can't destroy data: removal is a tombstone and the item table is rebuilt from the log. Changed upstream ids resolve through an alias table at read time, without touching user data.</p>
]]></content:encoded></item><item><title><![CDATA[Planning sourdough backwards in Swift, with quiet hours]]></title><description><![CDATA[How Proofday plans a sourdough bake backwards in Swift: a temperature model, a dynamic program on a 5-minute grid, quiet hours, Live Activities.]]></description><link>https://treamz.hashnode.dev/planning-sourdough-backwards-in-swift-with-quiet-hours</link><guid isPermaLink="true">https://treamz.hashnode.dev/planning-sourdough-backwards-in-swift-with-quiet-hours</guid><category><![CDATA[iOS]]></category><category><![CDATA[Swift]]></category><category><![CDATA[algorithms]]></category><category><![CDATA[SourdougH]]></category><dc:creator><![CDATA[Ivan Chernoknizhnikov]]></dc:creator><pubDate>Thu, 12 Nov 2026 17:27:48 GMT</pubDate><content:encoded><![CDATA[<p>Proofday is an iPhone sourdough planner: you say when you want bread, and it
works out when to feed the starter, mix, fold, shape and bake, adjusted for
kitchen temperature and kept out of the hours you sleep. It's
<a href="https://apps.apple.com/us/app/proofday-sourdough-planner/id6826169140">on the App Store</a>,
and <a href="https://treamz.me/blog/sourdough-schedule-planner-app">the post for bakers</a>
covers what it does. This one is about how: the fermentation model, the dynamic
program that plans backwards, how it refuses an impossible time, and the iOS
limits that shaped the reminders.</p>
<p>The plans below come from running the app's planning package, a Swift package
with no UI, on its Beginner Country Loaf recipe.</p>
<h2>The fermentation model is one formula</h2>
<p>Proofday's model says every 7 °C doubles or halves fermentation time, relative
to 24 °C:</p>
<pre><code class="language-swift">// FermentationConfig.swift
public var referenceTemperatureC: Double = 24
public var degreesPerDoubling: Double = 7
public var trustedTemperatureRangeC: ClosedRange&lt;Double&gt; = 16...30

// FermentationModel.swift
public func temperatureFactor(celsius: Double) -&gt; Double {
    let t = clamp(temperatureC: celsius).value
    return pow(2, (config.referenceTemperatureC - t) / config.degreesPerDoubling)
}
</code></pre>
<p>Bulk is 5 hours at 24 °C with 20% levain, so 18 °C gives a factor of 1.81 and
about 9 hours, and 30 °C gives 0.55 and 2.8 hours. Outside 16–30 °C the input is
clamped and the plan carries a warning, so 34 °C plans exactly like 30 °C
rather than trusting an exponential that far out.</p>
<p>The model's other constants, down to the starter peak table (5 to 14 hours at
24 °C, from 1:1:1 to 1:10:10 feeds), live in the same struct. Its doc comment
calls them "starting heuristics, not lab science".</p>
<h2>Learning from logged bakes</h2>
<p>When a starter peaks or a bulk ends, the app folds actual over predicted into a
per-starter moving average:</p>
<pre><code class="language-swift">public mutating func record(actual: TimeInterval, baselinePrediction: TimeInterval,
                            config: FermentationConfig = .standard) -&gt; Bool {
    guard actual &gt; 0, baselinePrediction &gt; 0 else { return false }
    let ratio = actual / baselinePrediction
    guard config.correctionSampleSanityRange.contains(ratio) else { return false }
    let alpha = config.correctionSmoothing
    let next = alpha * ratio + (1 - alpha) * value
    value = min(max(next, config.correctionRange.lowerBound), config.correctionRange.upperBound)
    sampleCount += 1
    return true
}
</code></pre>
<p>α is 0.3, the factor stays within 0.6–1.6, and a ratio outside 0.25–4 counts as
a logging mistake. The part that matters is the argument name. Predictions are
already multiplied by the factor, so a sample has to be compared with the
<em>uncorrected</em> baseline. Compare with the corrected one and the average settles
at the square root of the true factor: a starter that really takes 1.44 times as
long gets learned as 1.2. So each feeding and each plan stores its uncorrected
estimate up front.</p>
<h2>Planning backwards is a shortest path</h2>
<p>A recipe is a chain of typed steps. Each resolves into a minimum, ideal and
maximum length, hands-on minutes, and the moments the baker acts: the step's
start, each fold, and the oven preheat as a negative offset from the bake. Bulk
doesn't flex at plan time; the aliquot jar decides when it really ends. The
cold proof (8 to 24 hours), autolyse, bench rest, cooling and the levain do.</p>
<p>Rounding every length to a 5-minute grid turns the search into a dynamic
program. The state is "step i starts at time t", filled in from the ready time
backwards:</p>
<pre><code class="language-swift">for i in stride(from: n - 1, through: 0, by: -1) {
    var next: [Int: Node] = [:]
    for t in levels[i + 1].keys.sorted(by: &gt;) {
        let node = levels[i + 1][t]!
        for (vi, variant) in steps[i].enumerated() {
            let s = t - variant.duration
            if s &lt; earliestStart { continue }
            let v = violations(of: variant, at: s, anchorExempt: false)
            if hard &amp;&amp; v &gt; 0 { continue }
            let cost = node.cost + variant.cost + Double(v) * violationCost
            if let existing = next[s], existing.cost &lt;= cost { continue }
            next[s] = Node(cost: cost, violations: node.violations + v, link: t, variant: vi)
        }
    }
    if next.isEmpty { return nil }
    levels[i] = next
}
</code></pre>
<p><code>levels[n]</code> holds the ready time at cost zero. A variant is one layout of a
step: a length, plus a feed ratio for the levain or a preheat length for the
bake. Keeping only the cheapest path into each boundary is exact because a
step's cost and quiet-hours check depend only on its own start and length. On a
tie the later start wins, so you wait less.</p>
<p>Quiet hours default to 23:00–07:00, and staying out of them has two shapes. An
action is a point and may sit on the boundary, so a fold at 7:00 is fine. Hands-on minutes are a span and may not
overlap at all, so a 15-minute shape has to start by 22:45.</p>
<h2>Preferences are just prices</h2>
<p>There's no rule saying "stretch the cold proof first". There are costs:</p>
<pre><code class="language-swift">public var costPerMinute: Double {
    switch self {
    case .coldProof: 0.05
    case .levain: 0.15
    case .autolyse, .rest: 0.2
    case .cool, .roomProof: 0.3
    case .benchRest: 0.5
    default: 1
    }
}
// FermentationConfig
public var feedRatioSwitchCost: Double = 20
public var quietHoursViolationCost: Double = 10_000
</code></pre>
<p>A cold proof minute away from the ideal is the cheapest thing on the list, a
third of a levain minute. A different feed ratio costs 20, as much as moving the
cold proof 400 minutes. So a plan bridges the night with the fridge when it can,
and reaches for a 1:5:5 or 1:10:10 overnight feed when that's cheaper.</p>
<p>For the Beginner Country Loaf at 23 °C, ready Saturday at 10:00, the 12 steps
expand to 182 variants, 70 of them for the levain alone:</p>
<pre><code>Fri 07:10  levain       465 min  1:2:2
Fri 14:55  autolyse      60 min
Fri 15:55  mix           15 min
Fri 16:10  bulk         330 min  folds 16:40, 17:10, 17:40, 18:10
Fri 21:40  preshape       5 min
Fri 21:45  bench rest    30 min
Fri 22:15  shape         15 min
Fri 22:30  cold proof   555 min  (ideal 720)
Sat 07:45  bake          20 min  preheat from 07:00
Sat 08:05  lid off       25 min
Sat 08:30  cool          90 min  (ideal 120)
Sat 10:00  ready
</code></pre>
<p>The preheat can't start before 7:00, which pins the bake and cuts cooling to 90
minutes; the plan says so in a note. On my M1 Pro, in a release build, that
takes about a millisecond. The app still solves in a detached task, since the
setup screen re-plans on every input change.</p>
<p>Rough edges: windows longer than two hours are searched in 15-minute
steps, so the cold proof can't be 565 minutes and the levain starts at 7:10
rather than 7:00. And the cold proof used to stop at 16 hours, the figure I
started from, which made dinner-time bread impossible with the default quiet
hours. It now goes to 24: ready at 19:00 on Saturday means shaping at 22:45 on
Friday and a 17 h 15 min proof.</p>
<h2>Saying no, with a reason and a better time</h2>
<p>When the hard solve fails, the planner works out why, in order: the target has
passed, the step minimums don't fit, the target is inside quiet hours, or some
action would be. For that last case it reruns the DP in soft mode, where a
violation costs 10,000 instead of being forbidden, and reports the first action
that lands in quiet hours. Then it walks a 15-minute grid outward,
up to 48 hours each way, until a ready time solves.</p>
<p>From the package:</p>
<ul>
<li><strong>Saturday 9:00, asked on Thursday:</strong> the first violation is the preheat at
6:10 AM. Nearest: 9:30.</li>
<li><strong>Saturday 10:00, asked Friday at 8 PM:</strong> the bake needs at least 21 h 20 min.
Nearest: Sunday 9:30.</li>
<li><strong>Sunday 9:30, asked Saturday at noon:</strong> shaping would land at 11:30 PM.
Nearest: Monday 9:30. With "My starter is already ripe" on, Sunday 9:30
works, shaping at 7:30 PM.</li>
</ul>
<p>The third looked like a bug. It isn't: with a levain to build, the
earliest Saturday shape is about half an hour too late, and mixing on Sunday
morning puts the bread after 11 PM. These refusals took roughly 3 to 13 ms, mostly
the search for alternatives.</p>
<h2>Re-planning while the dough is out</h2>
<p>Done, +15 min and Need more time re-run the solver forwards from the current
step, in soft mode, with one more price: each minute the ready time drifts from
the promise costs 1, against 0.05 for a cold proof minute. In the plan above,
asking for 30 more minutes of bulk at 9:30 PM keeps 10:00 and shortens the cold
proof to 525 minutes. Asking for 45 can't be absorbed, because shaping would run
from 23:00 to 23:15. The plan keeps 10:00 and flags that action as inside quiet
hours rather than moving the bread.</p>
<h2>Notifications, Live Activities and widgets</h2>
<p>Every pending action is a local notification, rescheduled after any change.
The triggers are <code>UNTimeIntervalNotificationTrigger</code>, elapsed seconds rather
than calendar components, so a DST change can't move an absolute moment.
A time-zone change re-solves the plan, since quiet hours follow the wall clock.
Done, +15 min and Snooze 5 min carry no <code>.foreground</code> option, so
<a href="https://developer.apple.com/documentation/usernotifications/declaring-your-actionable-notification-types">iOS hands them to the app in the background</a>.</p>
<p>Live Activities were the awkward part. One
<a href="https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities">lasts up to eight hours, twelve on the Lock Screen</a>,
and <a href="https://developer.apple.com/documentation/activitykit/activity/request(attributes:content:pushtype:)"><code>Activity.request</code></a>
only works while the app is in the foreground. The Saturday plan runs almost 27
hours. So:</p>
<pre><code class="language-swift">static let startWindow: TimeInterval = 7 * 3600

func sync(planID: UUID, schedule: BakeSchedule, presenter: ActionPresenter, now: Date = .now) async {
    guard !schedule.isComplete, let state = Self.state(for: schedule, presenter: presenter, now: now) else {
        await end(planID: planID)
        return
    }
    let content = ActivityContent(state: state, staleDate: state.nextDate.addingTimeInterval(30 * 60), relevanceScore: 100)
    if await Self.update(planID: planID, content: content) { return }
    guard ActivityAuthorizationInfo().areActivitiesEnabled,
          state.nextDate.timeIntervalSince(now) &lt; Self.startWindow else { return }
    _ = try? Activity.request(
        attributes: BakeActivityAttributes(planID: planID, recipeName: schedule.recipeName),
        content: content, pushType: nil)
}
</code></pre>
<p><code>sync</code> runs after every plan change and whenever the app comes to the
foreground. It updates a running activity, or starts one when the next action is
under seven hours away, inside the eight-hour limit. There's no push server, so
the content changes only when the app's code runs, but the countdown keeps
moving because it's <code>Text(timerInterval:)</code>, drawn by the system. The catch,
stated in the user docs: it appears once you open the app within seven hours of
a step. iOS 26 added
<a href="https://developer.apple.com/documentation/activitykit/activity/request(attributes:content:pushtype:style:alertconfiguration:start:)">a <code>request</code> that takes a start date</a>
and starts the activity with the app in the background. Proofday supports iOS
17 and doesn't use it.</p>
<p>Its Done and +15 buttons are <code>LiveActivityIntent</code>s, which
<a href="https://developer.apple.com/documentation/appintents/liveactivityintent">run in the app's process</a>,
through a static bridge the app delegate fills at launch. Under Swift 6,
<code>Activity</code> isn't <code>Sendable</code>, so its handles stay inside <code>nonisolated</code> helpers.</p>
<p>Widgets never open SwiftData: the app writes a Codable snapshot of the next
seven actions to the App Group, and the timeline adds an entry as each comes
due, so the Next bake step widget advances on its own.</p>
<h2>App Attest for the one server call</h2>
<p>Crumb Check, a photo analysis of the cut loaf, is the only server feature: a
small proxy in front of a Claude model. Each install makes a key with
<code>DCAppAttestService</code> and has Apple attest it once. After that, every request carries an assertion over a single-use challenge plus
the SHA-256 of the body, and the server checks signature, counter and app ID
(<a href="https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity">Apple's overview</a>).
It allows 30 checks per install per day and stores only public keys,
challenges and counters.</p>
<p>What the app does for the people who use it: <a href="https://treamz.me/blog/sourdough-schedule-planner-app">Sourdough schedule planner that works back from bread time</a>.</p>
<h2>FAQ</h2>
<h3>How do you schedule tasks backwards from a deadline with time windows?</h3>
<p>Round durations to a grid and run a dynamic program from the deadline back:
the state is "step i starts at time t", each step offers one choice per allowed
length, choices that put an action in a blocked window are dropped, and the
cheapest path into each boundary is kept. Proofday does this on a 5-minute grid.</p>
<h3>How does Proofday estimate fermentation time at different temperatures?</h3>
<p>It multiplies a duration at 24 °C by 2^((24 − T) / 7), so every 7 °C cooler
doubles it: a 5-hour bulk becomes about 9 hours at 18 °C. It's trusted between
16 and 30 °C, and a per-starter factor learned from logged bakes scales it.</p>
<h3>When can an iOS app start a Live Activity?</h3>
<p>With <code>Activity.request</code>, only while the app is in the foreground, or from a
<code>LiveActivityIntent</code>. From iOS 26 an app can also schedule one for a start
date. A Live Activity lasts up to eight hours, then can stay on the Lock Screen
for up to four more.</p>
<h3>How do you keep local notifications correct across daylight saving time?</h3>
<p>Use <code>UNTimeIntervalNotificationTrigger</code> with the seconds until the absolute
moment, not calendar components, so a clock change can't shift it. If times
follow the wall clock, re-plan and reschedule on <code>NSSystemTimeZoneDidChange</code>
and <code>UIApplication.significantTimeChangeNotification</code>.</p>
<h3>How do you learn a correction factor from logged data without bias?</h3>
<p>Average the ratio of actual duration to the uncorrected prediction and multiply
predictions by the result. Averaging against the already-corrected prediction
settles at the square root of the true factor. Proofday uses an exponential
moving average with weight 0.3, clamped to 0.6–1.6.</p>
<h3>What should a scheduler do when a deadline is impossible?</h3>
<p>Say why and offer the nearest time that works. Proofday finds the offending
action by re-running its solver with quiet-hour violations penalised instead of
forbidden, then searches a 15-minute grid up to 48 hours either side for the
closest feasible ready time.</p>
]]></content:encoded></item><item><title><![CDATA[Counting reps with Vision and locking apps with Screen Time]]></title><description><![CDATA[Counting push-ups from Vision body pose with one state machine, and what Screen Time's shields, callbacks and 20-activity limit really allow.]]></description><link>https://treamz.hashnode.dev/counting-reps-with-vision-and-locking-apps-with-screen-time</link><guid isPermaLink="true">https://treamz.hashnode.dev/counting-reps-with-vision-and-locking-apps-with-screen-time</guid><category><![CDATA[iOS]]></category><category><![CDATA[Swift]]></category><category><![CDATA[Computer Vision]]></category><category><![CDATA[Screen Time]]></category><dc:creator><![CDATA[Ivan Chernoknizhnikov]]></dc:creator><pubDate>Thu, 12 Nov 2026 17:26:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af5f400c3f8ded745c2257b/842d2974-babb-4081-895f-14b18191b848.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Pull to Refresh is my iPhone app that locks the apps you choose and unlocks them
for exercise reps counted by the camera
(<a href="https://apps.apple.com/us/app/pull-to-refresh-screen-time/id6809189842">App Store</a>).
This is the developer side: counting reps from Vision joint positions, what
Apple's Screen Time API lets a third-party app do, and the bugs that taught me
both. What the app does for its users is in
<a href="https://treamz.me/blog/app-blocker-unlock-with-exercise">the user-facing post</a>.</p>
<h2>One number per exercise, one state machine</h2>
<p><a href="https://developer.apple.com/documentation/vision/vndetecthumanbodyposerequest"><code>VNDetectHumanBodyPoseRequest</code></a>
runs on every frame of a 1280×720 front-camera feed. I keep joints with a
confidence of 0.3 or more and drop the frame; nothing is recorded.</p>
<p>What kept the counter small: every exercise reduces a pose to one <code>Double</code>,
oriented so that low means contracted and high means extended. Push-ups, squats
and sit-ups use a joint angle, averaged over both sides of the body when both
are visible. Pull-ups and jumping jacks use the nose's height against the
wrists, divided by torso length, so standing further back doesn't change the
number.</p>
<p>One state machine then scores all five; each exercise supplies two thresholds
and a speed limit:</p>
<table>
<thead>
<tr>
<th>Exercise</th>
<th>Signal</th>
<th>Contracted</th>
<th>Extended</th>
<th>Min. gap</th>
</tr>
</thead>
<tbody><tr>
<td>Push-up</td>
<td>elbow angle</td>
<td>100°</td>
<td>150°</td>
<td>0.7 s</td>
</tr>
<tr>
<td>Squat</td>
<td>knee angle</td>
<td>110°</td>
<td>160°</td>
<td>0.7 s</td>
</tr>
<tr>
<td>Sit-up</td>
<td>shoulder–hip–knee angle</td>
<td>80°</td>
<td>120°</td>
<td>0.7 s</td>
</tr>
<tr>
<td>Pull-up</td>
<td>hands above nose, in torsos</td>
<td>0.0</td>
<td>0.35</td>
<td>1.0 s</td>
</tr>
<tr>
<td>Jumping jack</td>
<td>nose above hands, in torsos</td>
<td>0.0</td>
<td>0.8</td>
<td>0.4 s</td>
</tr>
</tbody></table>
<p>The gap between the thresholds is the hysteresis: the phase flips to
<code>.contracted</code> below <code>contracted</code>, and a rep counts only on the way back above
<code>extended</code>:</p>
<pre><code class="language-swift">case .contracted:
    if value &gt;= model.extended {
        phase = .extended
        let now = sample.timestamp
        if let last = lastRepAt, now.timeIntervalSince(last) &lt; model.minRepInterval {
            return .formHint("Slow down — that one didn't count.")
        }
        lastRepAt = now
        reps += 1
        return .counted(reps)
    }
</code></pre>
<p>The raw value is smoothed first (<code>smoothed + 0.35 * (raw - smoothed)</code>), because
joints jitter between frames. A third line, 35% of the way down, catches
half-reps: dip past it and come back without reaching depth, and you get a
hint to go deeper instead of silence. Up to eight frames of missing joints
pass before the screen says it can't see you.</p>
<p>The tests drive the counter with synthetic geometry: a pose builder that
produces an exact elbow angle, swept through a cosine at 30 fps. Ten clean
push-ups must give 10, half-reps 0 and a hint, and twenty 0.2-second cycles,
someone shaking the phone, at most 4. Not zero: smoothing and the speed floor
limit how fast reps pay, but nothing recognises shaking as such.</p>
<p>One dead end. I saw zero reps on a screenshot from the start of a squat set,
decided a knee angle can't be read head-on and moved squats to a side view.
Later frames of that set were counting fine. I reverted it the same day and
fixed the setup hint instead, which had been asking people to stand side-on in
front of the selfie camera. Now every exercise opens on the front camera, so the
person doing the reps can see the count.</p>
<h2>Five targets and an App Group</h2>
<p>Screen Time work is split across processes that can't call each other. The app
picks apps with <code>FamilyActivityPicker</code> and gets opaque tokens, never names. A
<code>DeviceActivityMonitor</code> extension wakes when an interval starts or ends or a
usage threshold is crossed. Two shield extensions draw the lock screen and
handle its buttons. A
<a href="https://developer.apple.com/documentation/deviceactivity/deviceactivityreportextension"><code>DeviceActivityReport</code> extension</a>
is the only process that sees per-app usage, and it can only render a SwiftUI
view: the app shows a "minutes left" card without ever learning the numbers.</p>
<p>They share state through App Group <code>UserDefaults</code>, plus a Darwin notification to
tell a running app something changed. Each rule has its own named
<code>ManagedSettingsStore</code>, so rules shield and lift independently. The monitor is
handed nothing but an activity name, so the name carries the context:</p>
<pre><code class="language-swift">static func make(_ kind: ActivityKind, rule: UUID, slot: Int = 0) -&gt; DeviceActivityName {
    DeviceActivityName("\(kind.rawValue)|\(rule.uuidString)|\(slot)")
}

var decoded: (kind: ActivityKind, rule: UUID, slot: Int)? {
    let parts = rawValue.split(separator: "|", omittingEmptySubsequences: false)
    guard parts.count == 3,
          let kind = ActivityKind(rawValue: String(parts[0])),
          let id = UUID(uuidString: String(parts[1])),
          let slot = Int(parts[2]) else { return nil }
    return (kind, id, slot)
}
</code></pre>
<p>Two documented limits shape everything: an app and its extensions can monitor
<a href="https://developer.apple.com/documentation/deviceactivity/deviceactivitycenter/monitoringerror/excessiveactivities">twenty activities</a>,
and an interval can't be shorter than
<a href="https://developer.apple.com/documentation/deviceactivity/deviceactivitycenter/monitoringerror/intervaltooshort">fifteen minutes</a>.
Schedules repeat daily, not hourly, so "five minutes an hour" costs one
registration per hour of its window. Windows are capped at 16 hours, and one
slot always stays free for unlocks.</p>
<h2>Callbacks are not transitions</h2>
<p>Up to 1.4 the code did the obvious thing: <code>intervalDidStart</code> lifts the shield,
the threshold puts it back. The docs for
<a href="https://developer.apple.com/documentation/deviceactivity/deviceactivitycenter/startmonitoring(_:during:events:)"><code>startMonitoring</code></a>
say callbacks may arrive as soon as you call it if the interval is running.
Saving a rule re-registered every budget, the registration echoed
<code>intervalDidStart</code>, and the shield came down. And unless an event opts into
counting past activity, which needs iOS 17.4, it counts usage only from the
moment it's registered, so a save handed a spent allowance back in full.
Stopping an activity echoes <code>intervalDidEnd</code>, so switching a schedule off
mid-lock shielded the rule I'd just been told was off.</p>
<p>The fix was to let no callback decide. Each one hands the rule, as it is now,
to one <code>apply</code>, which asks a pure policy function whether it should be open:</p>
<pre><code class="language-swift">override func intervalDidStart(for activity: DeviceActivityName) {
    super.intervalDidStart(for: activity)
    guard let decoded = activity.decoded, decoded.kind != .grant, decoded.kind != .warning,
          let rule = SharedState.rule(id: decoded.rule) else { return }
    // A fresh allowance, a schedule's boundary, or registering this
    // activity echoing straight back. Lifting outright here is what let
    // every save reopen an allowance already spent.
    ShieldEngine.apply(rule)
    StateBridge.postChanged()
}
</code></pre>
<p>That works because of a record: when a threshold fires, the monitor writes down
that the allowance is spent for the current day or hour, so an echo finds the
rule spent and keeps it shut. A save now registers only the rule saved, and
only when something iOS enforces changed. From iOS 17.4, budgets pass
<a href="https://developer.apple.com/documentation/deviceactivity/deviceactivityevent/init(applications:categories:webdomains:threshold:includespastactivity:)"><code>includesPastActivity: true</code></a>
and count the whole day or hour however late they're registered.</p>
<p>Earned time is a one-shot activity whose event threshold is the time bought.
The threshold counts use of those apps, so putting the phone down doesn't burn
paid minutes. The interval end is a backstop: four times the unlock, between 30
minutes and 22 hours. It's registered before anything is stored or lifted; 1.4
did it the other way round, so a refusal at the twenty-activity limit left the
rule open with nothing to close it. And unlock records once lived in one array
that three processes read and wrote whole, so one could resurrect an unlock
another had just ended. Each record has its own key now.</p>
<h2>From the shield back into the app</h2>
<p>The shield's button belongs to the shield action extension, and for years it
couldn't launch its containing app. The only way back was a local notification,
easy to miss with a Focus on. My first version posted it and returned <code>.close</code>
in the same breath; closing the shield lets iOS terminate the extension, and the
notification sometimes went with it.</p>
<p>iOS 26.5 added
<a href="https://developer.apple.com/documentation/managedsettings/shieldactionresponse/openparentalcontrolsapp"><code>ShieldActionResponse.openParentalControlsApp</code></a>.
Both paths:</p>
<pre><code class="language-swift">SharedState.setPendingUnlock(targets.map(\.id))

if #available(iOS 26.5, *) {
    // Straight into the workout screen, with the rule already chosen.
    completionHandler(.openParentalControlsApp)
    return
}

// Closing the shield lets iOS terminate this extension, so the
// request has to be handed over before that happens.
Task {
    await notify(title: title, body: body, ruleID: carriedRule)
    completionHandler(.close)
}
</code></pre>
<p>The pending unlock goes into the App Group first, so the app opens on the
workout screen and spends the next set on that rule. With a minute already
banked, the button just spends it. The new case needs the iOS
26.5 SDK, so CI moved to macOS 26 runners. On older iOS with notifications off
there's no way back, and the app warns about it.</p>
<p>None of it ships without <code>com.apple.developer.family-controls</code>. The development
entitlement comes without asking and TestFlight worked with it; distribution is
by request, and
<a href="https://developer.apple.com/documentation/familycontrols/requesting-the-family-controls-entitlement">Apple's page</a>
says to file the same request for every Screen Time extension.</p>
<h2>Pricing a rep</h2>
<p>A clean rep earns 10 seconds for a jumping jack, 20 for a squat or sit-up, 30
for a push-up and 90 for a pull-up. Steps move the rate (×1.15, ×1.3, ×1.6 at
3,000, 8,000 and 15,000), each streak day adds 0.05 up to eight, and the total
caps at ×2. The rate locks when a set starts.</p>
<p>I started with "only reps buy time", and a test asserts no number of steps pays
a second without a rep. Then a user asked for a way in for people who can't do a
push-up today, so an opt-in mode now lets 1,000 steps buy 3 minutes, up to 30 a
day, with steps dropped from the multiplier so one walk is paid once. HealthKit
reports a running total, so what today already paid is subtracted, and a total
that drops never takes minutes back.</p>
<p>Steps typed into Health by hand don't count. My first predicate for that summed
to zero for everyone:</p>
<pre><code class="language-swift">// Must be a negated match, not a "not equal to" match.
let typedIn = HKQuery.predicateForObjects(
    withMetadataKey: HKMetadataKeyWasUserEntered, allowedValues: [true])
let notTypedIn = NSCompoundPredicate(notPredicateWithSubpredicate: typedIn)
</code></pre>
<p>A not-equal match only considers samples that carry the key, and step samples
from an iPhone or Watch don't.</p>
<p>None of this is unbreakable: deleting the app removes every lock, and a camera
can be fooled.</p>
<p>What the app does for the people who use it: <a href="https://treamz.me/blog/app-blocker-unlock-with-exercise">App blocker you unlock with push-ups: Pull to Refresh</a>.</p>
<h2>FAQ</h2>
<h3>Can you count push-ups with Apple's Vision framework?</h3>
<p>Yes. <code>VNDetectHumanBodyPoseRequest</code> returns shoulder, elbow and wrist positions
per frame, and the elbow angle follows from them. Pull to Refresh counts a
push-up, on the device, when the angle drops below 100° and comes back above
150°, with smoothing and at least 0.7 seconds between reps.</p>
<h3>Can a Screen Time shield open the app that blocked it?</h3>
<p>From iOS 26.5, yes: a <code>ShieldActionDelegate</code> can answer with
<code>ShieldActionResponse.openParentalControlsApp</code>. Before that, the fallback is a
local notification, and the extension should wait until the request is added
before calling its completion handler, or iOS may end it first.</p>
<h3>How many DeviceActivity schedules can an iOS app monitor at once?</h3>
<p>Twenty, across the app and its extensions; past that, <code>startMonitoring</code> throws
<code>excessiveActivities</code>. Each interval must last at least fifteen minutes, and since
schedules repeat daily, an hourly allowance needs one activity per hour.</p>
<h3>Can an iOS app read how long someone used another app?</h3>
<p>Not directly. Per-app usage goes only to a <code>DeviceActivityReport</code> extension,
which can render a SwiftUI view from it but can't pass the numbers back. Apps
arrive as opaque tokens, not names.</p>
<h3>Does the Screen Time API work in the iOS Simulator?</h3>
<p>No. FamilyControls, ManagedSettings and DeviceActivity have no Simulator
implementation, so authorization fails and no shield appears. You need a
physical iPhone.</p>
<h3>Do Screen Time extensions need their own Family Controls entitlement request?</h3>
<p>Yes. Apple's documentation says to submit the same distribution request for
each Screen Time extension: Device Activity Monitor, Device Activity Report,
Shield Action and Shield Configuration.</p>
]]></content:encoded></item><item><title><![CDATA[How to play animated SVG in Flutter (flutter_svg won't)]]></title><description><![CDATA[flutter_svg skips SMIL and CSS animation. How svg_animate plays animated SVG in Flutter, the bugs real files exposed, and Lottie/Rive compared.]]></description><link>https://treamz.hashnode.dev/how-to-play-animated-svg-in-flutter-flutter-svg-won-t</link><guid isPermaLink="true">https://treamz.hashnode.dev/how-to-play-animated-svg-in-flutter-flutter-svg-won-t</guid><category><![CDATA[Flutter]]></category><category><![CDATA[Dart]]></category><category><![CDATA[SVG]]></category><category><![CDATA[SVG Animation]]></category><dc:creator><![CDATA[Ivan Chernoknizhnikov]]></dc:creator><pubDate>Thu, 12 Nov 2026 17:22:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af5f400c3f8ded745c2257b/9ab8e827-c879-4c59-91ba-64ee26640788.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Short answer:</strong> flutter_svg doesn't play SVG animations: it skips SMIL
elements like <code>&lt;animate&gt;</code> and ignores <code>&lt;style&gt;</code>, so the file shows as a still
picture. To play the animation that's already in the SVG, add
<a href="https://pub.dev/packages/svg_animate">svg_animate</a> and use
<code>AnimatedSvgPicture</code> where you'd use <code>SvgPicture</code>. It takes the same arguments:</p>
<pre><code class="language-bash">flutter pub add svg_animate
</code></pre>
<pre><code class="language-dart">AnimatedSvgPicture.asset('assets/spinner.svg', width: 48)
</code></pre>
<p>It plays SMIL, CSS <code>@keyframes</code> and CSS motion paths, in pure Dart. The rest of
this post is how it works, the three bugs real files exposed, and how it
compares with Lottie, Rive and the other SVG packages.</p>
<p>Our designer sent me a folder of animated SVGs. A spinner, a progress bar, a
few icons. Nice stuff, all of it animated right there in the file with plain
CSS and SMIL.</p>
<p>I dropped them into flutter_svg and got static pictures.</p>
<p>I spent an evening convinced I'd broken something. Wrong widget, wrong asset
path, something dumb. Turns out no: flutter_svg just skips <code>&lt;animate&gt;</code>. And it
ignores <code>&lt;style&gt;</code> too, which is worse, because that means a stylesheet-driven
SVG doesn't even render with the right colors, let alone move.</p>
<p>The usual answer is "convert it to Lottie". I didn't want to go back to the
designer and ask him to rebuild everything in After Effects when the animation
was already sitting in the file he'd sent me. So I wrote svg_animate.</p>
<h2>How svg_animate works: a flipbook of static SVGs</h2>
<p>flutter_svg draws SVGs by compiling them into a binary format with
<code>vector_graphics_compiler</code>, then replaying that. That compiler is good. I had no
interest in writing a renderer.</p>
<p>So: don't. Take the animated document, work out what every animated attribute
is at time <em>t</em>, write those values back into the markup, and hand the result to
the existing compiler. Do that once per frame ahead of time, then flip through
the compiled frames like a flipbook.</p>
<p>A frame costs exactly what a static SVG costs to draw, because it <em>is</em> a static
SVG. You pay at load instead.</p>
<pre><code class="language-dart">AnimatedSvgPicture.asset('assets/spinner.svg', width: 48)
</code></pre>
<p>Same arguments as <code>SvgPicture</code>. Reuses flutter_svg's <code>SvgTheme</code> and
<code>ColorMapper</code> so nothing in an existing app breaks. If the file has no animation
in it, it renders like a normal <code>SvgPicture</code> and doesn't even start a ticker.</p>
<p>Two evenings later the spinner was spinning. I felt great about myself for
roughly a week.</p>
<h2>Bug 1: an SVGator banner that rendered blank</h2>
<p>A 450×450 promo banner exported from SVGator. Coins, sparkles, a train, a
"BUY BONUS" badge. 90 KB of markup with five WebP images base64'd into it.</p>
<p>It rendered as nothing at all. Not "wrong" — a blank box.</p>
<p>Here's where I want to be honest about how I debugged it, because I got it wrong
first. I built a minimal test case: a tiny SVG with one <code>&lt;image&gt;</code> in it,
referenced through <code>&lt;use&gt;</code>. Nothing drew. I tried the same file through plain
flutter_svg. Nothing drew there either. Great, I thought, images just don't work
on this pipeline, not my problem.</p>
<p>That conclusion was garbage. I'd hand-rolled the test PNG as a base64 string and
it was truncated — zlib couldn't even decompress it. I'd proven nothing except
that a broken PNG doesn't render.</p>
<p>Generated a real one. Ran it again. Images render fine. <code>&lt;use&gt;</code> pointing at an
image renders too, just at the wrong size.</p>
<p>So back to the real file, this time properly: feed the raw SVG straight into
<code>vector_graphics_compiler</code>, no code of mine involved.</p>
<pre><code>Failed assertion: 'width &gt; 0 &amp;&amp; height &gt; 0'  (writeDrawImage)
</code></pre>
<p>It dies in the compiler. Before any animation logic. <code>SvgPicture</code> would have
shown the same blank box.</p>
<p>Bisecting the file by deleting things: strip the 19 <code>&lt;use href="#img"&gt;</code>
references and it compiles. The compiler loses an image's declared width and
height when the image is reached through a reference, gets zero, and then
refuses to draw something of zero size — killing the whole picture rather than
that one element.</p>
<p>Fix on my side: expand a <code>&lt;use&gt;</code> that points at an <code>&lt;image&gt;</code> into the image
itself before compiling. Only images — a <code>&lt;use&gt;</code> on a shape is fine, and
copying those would just bloat the document.</p>
<p>The banner rendered. Coins and all.</p>
<h2>Bug 2: slow frames and 27.5 MB of memory</h2>
<p>Every compiled frame carries the images inside it, because each frame is a
self-contained picture. 82% of each 96 KB frame was image payload. At the
default 60 fps that's 300 frames and <strong>27.5 MB</strong>.</p>
<p>Worse than the memory: changing frame cost <strong>6.8 ms</strong>. The frame budget at 60fps
is 16.7 ms. That's 40% of it, just to decode, on an M-series Mac. On a mid-range
phone it would have blown straight through.</p>
<p>Two fixes, and I'm not proud of the first one.</p>
<p><strong>The image cache.</strong> I went reading how vector_graphics decodes images and found
this:</p>
<pre><code class="language-dart">final listener = FlutterVectorGraphicsListener(id: loader.hashCode, ...);
// ...
int _createImageKey(int imageId, int format) =&gt; Object.hash(_id, imageId, format);
</code></pre>
<p>It keys Flutter's global image cache by the hash code of the loader you hand it.
And mine was:</p>
<pre><code class="language-dart">int get hashCode =&gt; Object.hash(identityHashCode(frames), frameIndex);
</code></pre>
<p><code>frameIndex</code> in there. Every single frame opened its own namespace in the cache
and re-decoded the same five bitmaps. I had disabled a cache that was sitting
right there, with my own line of code, and then measured the result and blamed
the architecture.</p>
<p>Dropped <code>frameIndex</code>. Frames are still unequal to each other — that's what <code>==</code>
is for — and two unequal objects sharing a hash code is perfectly legal Dart.
Here it's the whole point. 6.8 ms → 1.4 ms.</p>
<p><strong>The duplicated bytes.</strong> For memory I nearly went and wrote a parser for the
binary format to find and hoist the image section. Then I realised I didn't need
to understand the format at all: consecutive frames of one animation are the
same document with a few numbers changed, and the encoder puts everything the
picture embeds at the front. So just compare the frames byte by byte and find
the longest run they all start with.</p>
<p>On the banner that run is 78,772 bytes. The compiler reports 78,538 bytes of
images. It found them, without knowing what it was looking at.</p>
<p>Store that once, keep only the tails, glue a frame back together when it's
asked for. 27.5 MB → 5.1 MB, and reassembling costs about 0.1 ms.</p>
<p>I like this one because it doesn't depend on the encoder's layout. If the format
ever stops front-loading resources, the shared run just gets shorter and nothing
breaks.</p>
<h2>Bug 3: a CSS spinner with no 0% keyframe</h2>
<p>Last one, and it's the most embarrassing, because it's the most common CSS
animation on earth:</p>
<pre><code class="language-css">@keyframes spin { to { transform: rotate(360deg) } }
</code></pre>
<p>No <code>0%</code>. My code filled the missing end from the element's own <code>transform</code>
attribute, and when the element didn't have one, it fell back to... the other
keyframe. So both ends said 360 and the thing sat perfectly still while
everything around it animated.</p>
<p>The missing end has to come from the value the property has <em>outside</em> the
animation — no rotation for a transform, fully opaque for an opacity. With one
wrinkle: you can't substitute an empty transform, because two transform lists
only interpolate if they're built the same way. The stand-in has to keep the
shape: <code>translate(0 0)</code> against a translate, <code>scale(1)</code> against a scale.</p>
<h2>What svg_animate doesn't support</h2>
<p>Filters. At all. <code>vector_graphics</code> drops <code>&lt;filter&gt;</code> entirely, so drop shadows
and glows just don't happen — the shapes draw, the effect doesn't. Same for
<code>mix-blend-mode: plus-lighter</code>, which is exactly what editors reach for to make
something glow.</p>
<p>Also no <code>d</code> morphing, no <code>&lt;script&gt;</code>, no <code>:hover</code>, no event-based <code>begin</code>. If you
need any of that, <a href="https://pub.dev/packages/full_svg_flutter">full_svg_flutter</a>
covers a lot more of the format. It's a good package. It also bundles a
JavaScript runtime, which is a fair price for what it does and not one I wanted
to pay for a spinner.</p>
<p>Mine adds two pure-Dart packages, both already in flutter_svg's dependency tree.</p>
<h2>svg_animate vs Lottie, Rive and other Flutter SVG packages</h2>
<p>"Animated SVG" means different things in different packages. Only some of them
play the animation that's already written into the file:</p>
<table>
<thead>
<tr>
<th>Package</th>
<th>What it animates</th>
<th>Plays the animation already in the SVG</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>svg_animate</td>
<td>SMIL, CSS <code>@keyframes</code>, CSS motion paths</td>
<td>Yes</td>
<td>Pure Dart, on flutter_svg's own compiler; no filters</td>
</tr>
<tr>
<td>flutter_svg</td>
<td>Nothing: draws a still picture</td>
<td>No</td>
<td>Skips <code>&lt;animate&gt;</code>, ignores <code>&lt;style&gt;</code></td>
</tr>
<tr>
<td>lottie</td>
<td>Lottie JSON files</td>
<td>No</td>
<td>The animation has to be rebuilt in After Effects and exported as Lottie</td>
</tr>
<tr>
<td>rive</td>
<td><code>.riv</code> files from the Rive editor</td>
<td>No</td>
<td>The animation has to be rebuilt in Rive</td>
</tr>
<tr>
<td>anim_svg</td>
<td>SMIL and CSS <code>@keyframes</code>, converted to Lottie at runtime</td>
<td>Yes, except SVGator's script-driven exports</td>
<td>Renders with the native thorvg engine through FFI</td>
</tr>
<tr>
<td>full_svg_flutter</td>
<td>Much more of SVG</td>
<td>Yes</td>
<td>Bundles a JavaScript runtime</td>
</tr>
<tr>
<td>animated_svg</td>
<td>A transition between two SVG images, such as play to pause</td>
<td>No</td>
<td>For icon toggles, not animated files</td>
</tr>
<tr>
<td>drawing_animation</td>
<td>A line-drawing effect on <code>&lt;path&gt;</code> strokes</td>
<td>No</td>
<td>Draws paths progressively</td>
</tr>
</tbody></table>
<p>If your designer already animated the file, in SVGator, by hand, or anywhere
else that writes SMIL or CSS, use a package from the "Yes" rows. If you're
starting from scratch and want a timeline editor, Lottie or Rive is the better
tool.</p>
<h2>Install svg_animate from pub.dev</h2>
<p><a href="https://pub.dev/packages/svg_animate">pub.dev/packages/svg_animate</a></p>
<p>SMIL, CSS <code>@keyframes</code>, CSS motion paths (which is how SVGator writes every
movement, so its exports work), play/pause/seek through a controller. It's one
of the <a href="https://treamz.me/projects/#svg-animate">projects I maintain</a>.</p>
<p>If you've got an SVG that renders wrong, send it. Every single thing in this
post came from someone's file not working.</p>
<h2>FAQ</h2>
<h3>Does flutter_svg support animated SVGs?</h3>
<p>No. flutter_svg draws an SVG as a still picture: SMIL elements such as
<code>&lt;animate&gt;</code> are skipped, and <code>&lt;style&gt;</code> blocks are ignored entirely, so CSS
<code>@keyframes</code> don't play and colours set in a stylesheet don't apply either.</p>
<h3>How do I play an animated SVG in Flutter?</h3>
<p>Add svg_animate with <code>flutter pub add svg_animate</code> and use
<code>AnimatedSvgPicture</code> where you would use <code>SvgPicture</code>. It takes the same
arguments:</p>
<pre><code class="language-dart">AnimatedSvgPicture.asset('assets/spinner.svg', width: 48)
</code></pre>
<p>It plays the SMIL and CSS keyframe animations in the file, and an
<code>AnimatedSvgController</code> adds play, pause and seek. A file with no animation in
it renders like a normal <code>SvgPicture</code>.</p>
<h3>Do I need Lottie to animate an SVG in Flutter?</h3>
<p>Not when the animation is already in the SVG. Files written with SMIL or CSS
<code>@keyframes</code>, including exports from SVGator, play as they are with
svg_animate, with no round trip through After Effects.</p>
<h3>What's the difference between svg_animate and animated_svg?</h3>
<p>They solve different problems. svg_animate plays the animation inside an SVG
file: SMIL elements and CSS <code>@keyframes</code>. animated_svg doesn't read animation
from the file; it transitions between two separate SVG images, such as a play
icon turning into a pause icon.</p>
<h3>Does svg_animate work with SVGator exports?</h3>
<p>Yes, with SVGator's CSS export: SVGator writes movement as CSS motion paths,
which svg_animate plays, and the image-heavy banner in this post is an SVGator
export. SVGator's JavaScript export depends on an embedded script, and
svg_animate doesn't run <code>&lt;script&gt;</code>.</p>
<h3>Which SVG animation features does svg_animate support?</h3>
<p>SMIL (<code>&lt;animate&gt;</code>, <code>&lt;animateTransform&gt;</code>, <code>&lt;animateMotion&gt;</code> and <code>&lt;set&gt;</code>), CSS
<code>@keyframes</code> and CSS motion paths. It interpolates numbers, colours, lengths
and transform lists, and it loads SVGs from assets, the network, files, memory
or a string.</p>
<h3>What doesn't svg_animate support?</h3>
<p>SVG filters, so no drop shadows or glows; <code>mix-blend-mode: plus-lighter</code>;
morphing a path's <code>d</code> attribute; <code>&lt;script&gt;</code>; <code>:hover</code>; and event-based <code>begin</code>.
For those, <a href="https://pub.dev/packages/full_svg_flutter">full_svg_flutter</a> covers
more of the format, at the cost of bundling a JavaScript runtime.</p>
<h3>Is svg_animate fast enough for production?</h3>
<p>Every frame is compiled ahead of time and costs the same to draw as a static
SVG. On the image-heavy banner in this post, switching frames went from 6.8 ms
to 1.4 ms once decoded images were cached, and memory went from 27.5 MB to
5.1 MB once the bytes every frame shares were stored only once.</p>
<h3>Which platforms does svg_animate run on?</h3>
<p>Android, iOS, macOS, Windows and Linux.</p>
]]></content:encoded></item></channel></rss>