<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Frank Milvus</title>
    <description>The latest articles on DEV Community by Frank Milvus (@frankmilvus).</description>
    <link>https://dev.to/frankmilvus</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3933443%2Fecd1a0f7-f9e1-4a34-9344-702addcc858c.png</url>
      <title>DEV Community: Frank Milvus</title>
      <link>https://dev.to/frankmilvus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/frankmilvus"/>
    <language>en</language>
    <item>
      <title>Your Flutter desktop app won't start: one corrupt preferences file</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Fri, 06 Nov 2026 23:24:37 +0000</pubDate>
      <link>https://dev.to/frankmilvus/your-flutter-desktop-app-wont-start-one-corrupt-preferences-file-201g</link>
      <guid>https://dev.to/frankmilvus/your-flutter-desktop-app-wont-start-one-corrupt-preferences-file-201g</guid>
      <description>&lt;p&gt;The report was short: the desktop build opens and closes immediately. No window, no error dialog, nothing to click. On the terminal, the process printed a stack trace and quit before anything appeared on screen.&lt;/p&gt;

&lt;p&gt;The cause was not a broken build, a missing library, or a failed update. It was one settings file with a missing bracket.&lt;/p&gt;

&lt;p&gt;This post is about that failure and the fix: how a single corrupt JSON file can stop a Flutter desktop app before its first frame, why wrapping the read in &lt;code&gt;try/catch&lt;/code&gt; does not save you, and how the app now repairs itself at launch without throwing away the evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  A launch that dies before it can report anything
&lt;/h2&gt;

&lt;p&gt;The terminal showed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unhandled Exception: FormatException: Unexpected character (at character 31943)
...notif_suppressed_136":1793799452696}"flutter.keystore_durability_expecte...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look closely at the quoted fragment. There is a closing &lt;code&gt;}&lt;/code&gt; after a timestamp, and then another key starts immediately: &lt;code&gt;…2696}"flutter…&lt;/code&gt;. That is not a value the app wrote. It is two JSON objects glued together — the remains of an interrupted write.&lt;/p&gt;

&lt;p&gt;The exception happened inside an awaited call in &lt;code&gt;main()&lt;/code&gt;, before &lt;code&gt;runApp()&lt;/code&gt;. An async &lt;code&gt;main&lt;/code&gt; without a top-level catch lets that exception escape, and the Flutter engine never gets a route to render. From the user's side, the app silently refuses to start.&lt;/p&gt;

&lt;p&gt;There is no error boundary for this class of bug. The crash reporting tool had not finished starting either, so there was no report to read. The only evidence was the terminal output and the file on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one file decides whether the app starts
&lt;/h2&gt;

&lt;p&gt;On mobile, &lt;code&gt;shared_preferences&lt;/code&gt; is backed by platform-native storage. On Linux and Windows, the plugins are different: both keep the entire preferences store in a single file called &lt;code&gt;shared_preferences.json&lt;/code&gt; in the app's support directory. On Linux that is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.local/share/com.folcadi.app/shared_preferences.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every key the app saves lives in that one JSON object. The plugin does not read keys lazily; it decodes the whole file once, inside &lt;code&gt;SharedPreferences.getInstance()&lt;/code&gt;, and hands you a map.&lt;/p&gt;

&lt;p&gt;That design is fine for settings. It becomes a critical path when the first thing your app does is read a setting. Folcadi restores the user's appearance preferences before &lt;code&gt;runApp()&lt;/code&gt; so the first frame is already in the right theme. That means launch order is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Flutter bindings initialize.&lt;/li&gt;
&lt;li&gt;The app asks for preferences.&lt;/li&gt;
&lt;li&gt;The plugin reads and decodes the JSON file.&lt;/li&gt;
&lt;li&gt;Only then does the app build its first widget.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 3 throws, steps 4 onward never happen.&lt;/p&gt;

&lt;p&gt;This is also why the bug was invisible in development. A fresh profile has a valid file. Tests that mock preferences never see the real decoder. The failure needs a file that was damaged outside the app's control — a power loss, a forced shutdown, a full disk during a write, or a sync tool touching the profile directory. We cannot point to the exact event that damaged this one, and that is the point: the app must survive a damaged file regardless of how it happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;try/catch&lt;/code&gt; around the read is too late
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to catch the exception where preferences are read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;SharedPreferences&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// use defaults&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That helps the call site you wrapped, and it is worth doing — the app now has exactly that guard around its startup restores. But it does not repair the store. Every other call to &lt;code&gt;getInstance()&lt;/code&gt; in the app still hits the same file and throws the same error. A settings screen opened ten minutes later fails in a different place, with a different stack trace, and the app looks broken in a new way.&lt;/p&gt;

&lt;p&gt;Worse, the failed read leaves the file untouched. The next launch repeats the failure. A one-time catch turns a hard crash into a permanent, scattered malfunction.&lt;/p&gt;

&lt;p&gt;The real problem is that the file is untrusted input. Treat it that way: inspect it before the plugin ever opens it, and remove it from the plugin's path when it is unusable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate before you initialize
&lt;/h2&gt;

&lt;p&gt;The repair runs immediately after &lt;code&gt;WidgetsFlutterBinding.ensureInitialized()&lt;/code&gt; and before any code touches &lt;code&gt;SharedPreferences&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;WidgetsFlutterBinding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ensureInitialized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;repairCorruptSharedPreferences&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// ...the rest of startup&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function has a simple contract. It returns &lt;code&gt;true&lt;/code&gt; if a corrupt file was moved aside, and &lt;code&gt;false&lt;/code&gt; if there was nothing to do — on web, on mobile, when the file is missing, or when the file is healthy.&lt;/p&gt;

&lt;p&gt;Inside, the checks are deliberately narrow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readAsString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonDecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// The plugin expects a JSON object; anything else is not a usable store.&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="n"&gt;FormatException&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;debugPrint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'PrefsRepair: corrupt preferences file: &lt;/span&gt;&lt;span class="si"&gt;$e&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whitespace-only is not corruption.&lt;/strong&gt; An empty file is what a fresh install or a cleared profile looks like. Renaming it would create noise and a pointless backup on every clean launch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Valid JSON is not automatically valid preferences.&lt;/strong&gt; A file containing &lt;code&gt;[1, 2, 3]&lt;/code&gt; decodes without error but cannot be a preferences map. A type check catches that case, which a &lt;code&gt;jsonDecode&lt;/code&gt;-only guard would miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check is cheap.&lt;/strong&gt; Reading one small file and decoding it once adds a few milliseconds to launch. It is negligible next to the network calls that used to run before the first frame, and it buys a guaranteed start.&lt;/p&gt;

&lt;p&gt;Platform detection lives in the same function. It returns early unless the target platform is Linux or Windows, so mobile and web keep their native storage untouched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kIsWeb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;defaultTargetPlatform&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;TargetPlatform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;linux&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;defaultTargetPlatform&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;TargetPlatform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;windows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file is injectable for tests, so the real plugin and the real app directory are not needed to prove the behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quarantine instead of delete
&lt;/h2&gt;

&lt;p&gt;A corrupt store is unusable. Deleting it would let the app start, but it would also destroy the only artifact that explains what happened. So the repair renames the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;backup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;${target.path}&lt;/span&gt;&lt;span class="s"&gt;.corrupt-&lt;/span&gt;&lt;span class="si"&gt;${DateTime.now().millisecondsSinceEpoch}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backup&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Rename can fail across filesystems; startup must still win.&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deleteError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timestamped suffix means repeated failures do not overwrite each other. The original bytes stay on disk for inspection, and the plugin creates a fresh empty map on the next read.&lt;/p&gt;

&lt;p&gt;The delete fallback is not defensive noise. Renames can fail for reasons unrelated to the file itself — a permission change, a directory that is on another mount, a locked path. The priority order is explicit: &lt;strong&gt;start the app first, preserve evidence second&lt;/strong&gt;. A failed repair returns &lt;code&gt;false&lt;/code&gt;, which is honest, but the startup guard below means even that does not keep the app down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second half: startup must degrade, not block
&lt;/h2&gt;

&lt;p&gt;Repairing the file handles the known failure. The broader rule is that startup initialization is best-effort: a plugin failing should cost you a setting, not the whole app.&lt;/p&gt;

&lt;p&gt;Four independent pieces of startup work run together in a &lt;code&gt;Future.wait&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;window chrome (desktop only, no-op elsewhere),&lt;/li&gt;
&lt;li&gt;the saved appearance preferences,&lt;/li&gt;
&lt;li&gt;crash reporting / Firebase initialization,&lt;/li&gt;
&lt;li&gt;local notification setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four are wrapped in a single &lt;code&gt;try/catch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;([&lt;/span&gt;
    &lt;span class="n"&gt;WindowChrome&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ensureInitialized&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;restoreAppearancePrefs&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;_initializeFirebase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;_initializeNotifications&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// A prefs/plugin failure must not prevent the app from starting:&lt;/span&gt;
  &lt;span class="c1"&gt;// the notifiers already hold their defaults.&lt;/span&gt;
  &lt;span class="n"&gt;debugPrint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'⚠️ Startup restore failed: &lt;/span&gt;&lt;span class="si"&gt;$error&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;$stack&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a deliberate trade-off. &lt;code&gt;Future.wait&lt;/code&gt; fails fast: the first error propagates and the remaining futures keep running unobserved. The app starts with whatever defaults the notifiers already hold. A user might get the light theme instead of the dark one they chose, or miss one restored preference. They still get a working app, and the log names the failure.&lt;/p&gt;

&lt;p&gt;The guard is not a substitute for the repair. If the file stays corrupt, every later preferences access still throws. The two layers solve different problems: one removes the bad input, the other guarantees the app does not hang on the splash screen while initialization fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four tests that matter
&lt;/h2&gt;

&lt;p&gt;The repair has four tests, all against real files in a temporary directory:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Malformed JSON&lt;/td&gt;
&lt;td&gt;Two JSON objects concatenated&lt;/td&gt;
&lt;td&gt;Renamed to &lt;code&gt;.corrupt-*&lt;/code&gt;, contents preserved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valid JSON, wrong shape&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[1, 2, 3]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Quarantined&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Healthy store&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{"flutter.theme_mode":"dark"}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Untouched, no backup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing or blank&lt;/td&gt;
&lt;td&gt;No file / whitespace only&lt;/td&gt;
&lt;td&gt;Not treated as corruption&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first test uses the actual shape from the crash log. Not an abstract invalid string — the kind of damage the app really saw. That matters, because the test documents the supported failure, not just the code path.&lt;/p&gt;

&lt;p&gt;The healthy-file case is just as important. A repair routine that quarantines valid preferences would reset user settings on every launch, and it would look like a much worse bug than the one it fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs the user
&lt;/h2&gt;

&lt;p&gt;Honesty about the trade-off matters more than a clean story.&lt;/p&gt;

&lt;p&gt;When the file is quarantined, &lt;strong&gt;settings reset to defaults&lt;/strong&gt;: theme, notification preferences, keyboard options, quick-reaction emoji, and similar local state. The old values remain in the &lt;code&gt;.corrupt-&amp;lt;epoch&amp;gt;&lt;/code&gt; backup, but the app does not attempt to salvage them. A partially decoded store is exactly the kind of guess that produces weird states.&lt;/p&gt;

&lt;p&gt;The secure session store is untouched, so the keyring-backed login survives. But on desktops where the keyring never proved durable, a plaintext fallback token is kept in this same preferences file. That copy is lost with the file, so a user on such a machine may need to sign in again.&lt;/p&gt;

&lt;p&gt;That is the trade: preferences are disposable, launch is not. A few lost settings beat an app that cannot open.&lt;/p&gt;

&lt;h2&gt;
  
  
  If it happens to you
&lt;/h2&gt;

&lt;p&gt;If a Flutter desktop app dies at launch with a &lt;code&gt;FormatException&lt;/code&gt; from &lt;code&gt;shared_preferences&lt;/code&gt;, you can recover it manually without waiting for a patch:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Close the app.&lt;/li&gt;
&lt;li&gt;Go to the app's support directory. On Linux, &lt;code&gt;~/.local/share/&amp;lt;app-id&amp;gt;/&lt;/code&gt;; on Windows, &lt;code&gt;%APPDATA%\&amp;lt;app-id&amp;gt;\&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Rename &lt;code&gt;shared_preferences.json&lt;/code&gt; to something else, or delete it.&lt;/li&gt;
&lt;li&gt;Start the app again. It will come up with default settings.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keep the renamed file if you want to diagnose what wrote the bad data. If the app starts and then fails somewhere else, the preferences file was not the cause — but a corrupt JSON file is one of the few startup failures with such a clean recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;p&gt;The bug was not in the preferences code. It was in the assumption that a file written by the app is always readable by the app.&lt;/p&gt;

&lt;p&gt;Three rules came out of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Any file the app reads at startup is untrusted input.&lt;/strong&gt; Validate the shape, not just the presence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repair at the boundary, not at every call site.&lt;/strong&gt; One guard before the plugin initializes is simpler and more complete than a catch in every screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preserve the failure.&lt;/strong&gt; Rename instead of delete; a corrupt file is a bug report you cannot reproduce later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app still has plenty of startup work that can fail. The difference is that a failure now produces a running app with default settings and a log line, instead of a process that exits before the window appears.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does this affect mobile?
&lt;/h3&gt;

&lt;p&gt;No. The repair returns early unless the platform is Linux or Windows. Android, iOS, macOS, and web use different storage backends and keep their existing behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not switch to a different storage package?
&lt;/h3&gt;

&lt;p&gt;The file format was not the problem; the missing validation was. Whatever storage package you pick, a file on disk can be truncated, replaced, or synced by something else. The fix belongs at the boundary, so it applies regardless of the backend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why keep the corrupt file instead of deleting it?
&lt;/h3&gt;

&lt;p&gt;Because it is the only evidence. The backup is timestamped and costs a few kilobytes. If the corruption repeats, having the previous file is how you tell a one-off crash from a write pattern that keeps damaging the store.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can the settings be recovered from the backup?
&lt;/h3&gt;

&lt;p&gt;Not automatically. The app deliberately starts from defaults rather than trying to decode a damaged map. For a one-off failure, most users will not care. For a recurring one, the backup is diagnostic material, not a restore point.&lt;/p&gt;




&lt;p&gt;If you build Flutter desktop apps, the cheap version of this lesson is: check the files your app reads before the first frame. A missing bracket should never be enough to keep a window closed.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Get Folcadi&lt;/strong&gt; — a language-exchange messenger with live translation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Official website:&lt;/strong&gt; &lt;a href="https://folcadi.com" rel="noopener noreferrer"&gt;folcadi.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.folcadi.app" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/folcadi-releases/releases/latest" rel="noopener noreferrer"&gt;.zip or installer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/folcadi-releases/releases/latest" rel="noopener noreferrer"&gt;.deb or portable tarball&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Articles:&lt;/strong&gt; &lt;a href="https://frankmilvus.github.io/folcadi-articles/" rel="noopener noreferrer"&gt;write-ups and covers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram:&lt;/strong&gt; &lt;a href="https://t.me/frank_says" rel="noopener noreferrer"&gt;@frank_says&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://facebook.com/folcadi" rel="noopener noreferrer"&gt;facebook.com/folcadi&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>desktop</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Read chats in the language you study: on-device translation in Flutter</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Wed, 04 Nov 2026 10:22:14 +0000</pubDate>
      <link>https://dev.to/frankmilvus/read-chats-in-the-language-you-study-on-device-translation-in-flutter-3dn7</link>
      <guid>https://dev.to/frankmilvus/read-chats-in-the-language-you-study-on-device-translation-in-flutter-3dn7</guid>
      <description>&lt;p&gt;Translation is usually a one-way door: the app guesses your mother tongue and everything lands in it. Before Babel opens the other direction. You choose the language you want to read messages in — often the language you are learning — download a language pack, and every incoming message is rendered in it, in the bubble where the text always was.&lt;/p&gt;

&lt;p&gt;Nothing else in the app moves. Same chat list, same bubbles, same replies. Only the text changes, converted on the phone, offline, with no server in the loop.&lt;/p&gt;

&lt;p&gt;This is how the mode works, why it is built around language packs instead of a translation API, and the three bugs that only appeared once the pipeline ran on real conversations.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "in place" means
&lt;/h2&gt;

&lt;p&gt;Most translation features add something: a second bubble, a "translated" section under the message, a spinner, a language label. Every one of those is a reminder that you are reading a translation, and every one of them changes the rhythm of a chat.&lt;/p&gt;

&lt;p&gt;Before Babel adds nothing. A bubble contains either the original text or the translated text, in the same place, with the same layout. Someone who did not know the mode existed would not notice a UI change.&lt;/p&gt;

&lt;p&gt;Two affordances keep it honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Long-press a translated message to &lt;strong&gt;Show original&lt;/strong&gt;; the same action flips it back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy&lt;/strong&gt; copies the text you are currently looking at, not the text underneath it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the text is already in the language you chose, or the message is emoji-only, nothing is translated. The bubble shows what was sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The learner's angle
&lt;/h2&gt;

&lt;p&gt;Language exchange apps are built around your mother tongue. The recommendation is always "read foreign messages in the language you already speak". That is backwards for someone who is studying.&lt;/p&gt;

&lt;p&gt;Consider someone learning Korean whose family writes in English. They set the read language to Korean, and the family group becomes daily reading practice in text they actually care about — not textbook sentences about airports, but "did you eat", "call me when you land", "your cousin passed the exam". Tap any message to check the original when the guess is wrong. Reply in English; nobody on the other side sees anything unusual.&lt;/p&gt;

&lt;p&gt;That is the whole idea: &lt;strong&gt;immersion using conversations that were going to happen anyway&lt;/strong&gt;, with no tutor to schedule and no second app to open.&lt;/p&gt;

&lt;p&gt;It is also why the feature does not reuse the profile's mother language. Mother language describes how you want the product to talk to you. The read language describes what you are practising. They are different settings, and conflating them was the first thing we removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Language packs, not a translation service
&lt;/h2&gt;

&lt;p&gt;The translation runs through Google ML Kit's on-device models. Each language pack is around 30 MB, downloaded from the platform's catalog and stored on the device.&lt;/p&gt;

&lt;p&gt;The consequences are the reason this design works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The message never leaves the phone.&lt;/strong&gt; Language identification and translation are local calls. There is no queue, no API key, no per-message cost, and no text retention to explain in a privacy policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works offline.&lt;/strong&gt; Once a pack is downloaded, a flight or a metro tunnel changes nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage is explicit.&lt;/strong&gt; The settings page lists every supported language with a Download button, and downloaded packs move to their own section with a switch and a delete button. The user can see exactly what is taking space and remove it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ML Kit supports 59 languages; the app's own list is around 130. The mode offers the intersection and shows the rest translated not at all — the original text simply stays. On Android it needs Google Play services; where the platform has no on-device translation, the mode reports itself as unavailable instead of pretending.&lt;/p&gt;

&lt;p&gt;The server-side translation endpoint that powers the manual &lt;strong&gt;Translate&lt;/strong&gt; action is untouched. That action still runs when you ask for it, in your mother language, and still shows its own translated section. Before Babel is a separate display mode with its own state, so the two never fight over the same bubble.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside the pipeline
&lt;/h2&gt;

&lt;p&gt;Every incoming message is queued, identified, and — if a pack for that language exists — translated into the active read language. The result is written to a per-message field and cached under a key that includes both the target language and the message id.&lt;/p&gt;

&lt;p&gt;The skip rules are where most of the behaviour lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sourceCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;sourceCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;sourceCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;'und'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Translators bake their target in at creation: rebuild them when the&lt;/span&gt;
&lt;span class="c1"&gt;// active language changes, or messages keep arriving in the old language.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_translatorTargetCode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;clearTranslators&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;_translatorTargetCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter more than they look:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Own messages are skipped.&lt;/strong&gt; Translating your own text into the language you are studying teaches you nothing and doubles the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already-translated messages are skipped.&lt;/strong&gt; The cache key is per target language, so switching the read language re-translates rather than showing a stale result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A source model may be downloaded on first contact.&lt;/strong&gt; If someone writes in a language you have never received, ML Kit fetches that source model at translate time. That is deliberate — "translate from anything into my chosen language" is the promise — but it means the first message in a new language can be slow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The queue processes one message at a time with a short gap, and pauses when the app is backgrounded. A burst of fifty messages should not heat the phone or stutter the scroll.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edge cases that shaped the design
&lt;/h2&gt;

&lt;p&gt;Three problems only showed up once the pipeline ran against a real chat, and all three are invisible on a synthetic test with five messages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Newest messages first
&lt;/h3&gt;

&lt;p&gt;Opening a chat queued the history oldest-first, which is the order the messages arrived in and the wrong order for the person holding the phone. The screen sits at the bottom of the conversation; translating the top of it means nothing visibly changes.&lt;/p&gt;

&lt;p&gt;The fix was to reverse the backfill and bound it to the most recent thirty messages, then give live incoming messages a priority slot at the front of the queue. The message that just arrived is translated before the history behind it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Translators are bound to their target
&lt;/h3&gt;

&lt;p&gt;A translator instance is created for a source/target pair. Ours were cached by source language, so switching the read language from Korean to English kept reusing translators built for Korean. The first few bubbles looked right — they were already translated — and then new messages arrived in the previous language.&lt;/p&gt;

&lt;p&gt;The fix tracks the target each cached translator was built for and rebuilds the map whenever the user switches. The comment in the snippet above exists because this bug will come back if someone "optimises" that check away.&lt;/p&gt;

&lt;h3&gt;
  
  
  Turning the mode off
&lt;/h3&gt;

&lt;p&gt;Disabling the mode stopped the queue but left the translated text on screen: no active target meant no re-render, so bubbles kept showing text the user had just switched away from. Clearing the in-place text, the stored language code, and the show-original flag on disable fixed it — including the case where the user deletes their last pack instead of touching the switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs, and where it stops
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~30 MB per language model.&lt;/strong&gt; Multiple languages mean multiple packs; deletion is one tap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packs are device-local.&lt;/strong&gt; They are not synced across devices and not tied to the account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android needs Google Play services&lt;/strong&gt;; without them the mode says so instead of failing silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Desktop and web are not supported.&lt;/strong&gt; The pipeline degrades to the original text rather than throwing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality is a translation engine's quality.&lt;/strong&gt; Short, everyday messages are the sweet spot. Idioms, slang, and long technical paragraphs are where it struggles, which is exactly why the show-original toggle is one long-press away.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing the parts that are easy to get wrong
&lt;/h2&gt;

&lt;p&gt;Two widget tests cover the behaviour that regressions would break quietly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one asserts that a bubble swaps to the translation in place when the translated text arrives, with no scroll and no rebuild of the list;&lt;/li&gt;
&lt;li&gt;one asserts the queue contract: newest-first backfill, the thirty-message bound, live-message priority, and duplicate skipping.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither is clever. Both exist because the bugs above were ordering bugs, and ordering bugs survive green tests that only check that translation happens at all.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does my message text leave my device?&lt;/strong&gt;&lt;br&gt;
No. Language identification and translation are on-device calls. Nothing in this mode talks to a server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it replace the manual Translate action?&lt;/strong&gt;&lt;br&gt;
No. That action still exists, still uses the server engine, and still renders its own section. The two are independent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I read a language I have not downloaded?&lt;/strong&gt;&lt;br&gt;
No. The pack has to exist on the device; that is what makes the mode work offline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens in group chats?&lt;/strong&gt;&lt;br&gt;
Same pipeline. Every incoming message is identified and translated individually, so a group with four languages is just four source models.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I go back to the original text?&lt;/strong&gt;&lt;br&gt;
Per message, yes — long-press and choose &lt;strong&gt;Show original&lt;/strong&gt;. Turning the mode off reverts everything at once.&lt;/p&gt;

&lt;p&gt;Before Babel is the feature we wanted for ourselves: the app becomes a reading exercise made of your own conversations. If you are studying a language, the most useful text you can read is a message from someone who actually wants to talk to you.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Get Folcadi&lt;/strong&gt; — a language-exchange messenger with live translation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Official website:&lt;/strong&gt; &lt;a href="https://folcadi.com" rel="noopener noreferrer"&gt;folcadi.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.folcadi.app" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/folcadi-releases/releases/latest" rel="noopener noreferrer"&gt;.zip or installer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/folcadi-releases/releases/latest" rel="noopener noreferrer"&gt;.deb or portable tarball&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Articles:&lt;/strong&gt; &lt;a href="https://frankmilvus.github.io/folcadi-articles/" rel="noopener noreferrer"&gt;write-ups and covers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram:&lt;/strong&gt; &lt;a href="https://t.me/frank_says" rel="noopener noreferrer"&gt;@frank_says&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a href="https://facebook.com/folcadi" rel="noopener noreferrer"&gt;facebook.com/folcadi&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>languagelearning</category>
      <category>mobile</category>
      <category>folcadi</category>
    </item>
    <item>
      <title>Pre-ticked boxes are not consent: notification prompts default to off</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Sun, 01 Nov 2026 02:18:44 +0000</pubDate>
      <link>https://dev.to/frankmilvus/pre-ticked-boxes-are-not-consent-notification-prompts-default-to-off-e20</link>
      <guid>https://dev.to/frankmilvus/pre-ticked-boxes-are-not-consent-notification-prompts-default-to-off-e20</guid>
      <description>&lt;p&gt;Notification permissions are usually asked for badly. The app appears for the first time, and before the user has read a single message it shows the operating system's permission dialog. If they say no, the prompt is gone for good and the app quietly decides that notifications are not for this person. If they say yes, the app now has a blank cheque for message alerts, calls, reminder emails, and whatever marketing is invented next.&lt;/p&gt;

&lt;p&gt;We shipped the opposite last week in Folcadi, a language-exchange messenger with a Flutter client. After login, once, the app shows its own preference sheet with four channels. Every switch starts off. Nothing is pre-ticked, nothing is bundled, and "Save" with everything off is a complete, accepted answer.&lt;/p&gt;

&lt;p&gt;This post is about why the defaults are the whole product decision, and about the code shapes that follow from taking consent seriously: tri-state columns, a fail-open call gate, and an unsubscribe link that works without logging in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt we replaced
&lt;/h2&gt;

&lt;p&gt;The old app had three problems, and they are the usual three.&lt;/p&gt;

&lt;p&gt;The first was &lt;strong&gt;timing&lt;/strong&gt;. The startup path requested the OS notification permission for every user, whether or not they had ever wanted a notification. On Android 13 and later that dialog is a one-shot resource: a reflexive "don't allow" during launch is hard to undo, and a lot of users never find the settings page to reverse it.&lt;/p&gt;

&lt;p&gt;The second was &lt;strong&gt;bundling&lt;/strong&gt;. One "notifications" concept covered message alerts, incoming-call ringing, a reminder email for unread chats, and product news. Those are four different offers with four different costs to the user. A person who wants to know about a missed call may not want a marketing email, and an app that conflates them has decided that for them.&lt;/p&gt;

&lt;p&gt;The third was &lt;strong&gt;the data model&lt;/strong&gt;. There was no record of anybody's preference, because there was no preference to record. The server mailed and pushed until something errored, and the only way to stop it was to silence the app at the OS level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four purposes, four switches
&lt;/h2&gt;

&lt;p&gt;The new sheet is one themed dialog with four rows and a matching set of toggles in Settings → Notifications:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Starts&lt;/th&gt;
&lt;th&gt;What it gates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Message notifications&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;Push alerts for new messages; turning it on asks the OS permission and registers this device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incoming calls&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;Ringing for background calls; account-level, not per device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email reminders&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;One reminder email if messages stay unread for days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product news and offers&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;A separate marketing consent; nothing is sent under it yet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The copy is plain, and each row says what it does rather than what it is called: "Ring for incoming calls on your devices", "Email me if messages stay unread for a few days". The footer states the deal in one line: we only send what you select, and every reminder email carries a one-click unsubscribe link.&lt;/p&gt;

&lt;p&gt;Separating the channels is not just politeness. It is the difference between consent that is &lt;strong&gt;specific&lt;/strong&gt; and consent that is a blanket. A single switch forces a user who wants call alerts to accept marketing to get them, or to refuse everything to avoid marketing. Both outcomes are worse than four honest questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why pre-ticked is not consent
&lt;/h2&gt;

&lt;p&gt;In the EU, consent under the GDPR and the ePrivacy rules has to be freely given, specific, informed and unambiguous. Silence, inactivity, and pre-ticked boxes do not count. That is not an interpretation; the Court of Justice said it directly in &lt;em&gt;Planet49&lt;/em&gt; (C‑673/17, 2019), a case about pre-ticked cookie checkboxes. Ticking a box for someone, or treating "they did not untick it" as agreement, fails every part of that test.&lt;/p&gt;

&lt;p&gt;I am not a lawyer and this is not legal advice. But the engineering consequence is simple, and it holds even in places where marketing consent is not a legal question at all: &lt;strong&gt;a default is a statement about who is making the decision.&lt;/strong&gt; If the switch is on before the user arrives, the app made the decision and the user is only correcting it. If the switch is off, the user decides. That is also the only version that survives contact with an audit, an app-store review, or a reader asking "how do I stop this".&lt;/p&gt;

&lt;p&gt;Marketing email is the strictest case. It gets its own switch, its own consent stamp, and its own unsubscribe path, even though we are not sending any yet. Asking for consent before you need it is fine. Assuming it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consent is a tri-state, not a boolean
&lt;/h2&gt;

&lt;p&gt;Here is the part that sounds like pedantry and is not. A boolean column cannot tell the difference between "never asked" and "said no". If you treat both as false, then adding the prompt silently turns off call ringing for every existing user who has not opened the app since the release — a change nobody agreed to, discovered when somebody misses a call.&lt;/p&gt;

&lt;p&gt;So the columns that represent a &lt;strong&gt;choice&lt;/strong&gt; are nullable, and the third state is meaningful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#   None  -&amp;gt; no choice recorded yet: keep the previous behaviour
#   True  -&amp;gt; opted in
#   False -&amp;gt; explicitly opted out
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationConsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;push_alerts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;call_alerts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;email_reminders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;product_news&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Email reminders and product news default to &lt;code&gt;false&lt;/code&gt; because there is no older behaviour to preserve: they are new channels, and new channels start off. Push and call alerts default to &lt;code&gt;NULL&lt;/code&gt;, because they already worked, and a user who has not been asked must keep what they had. Only an explicit &lt;code&gt;false&lt;/code&gt; suppresses anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;opted_out_of_calls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NotificationConsent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;NotificationConsent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;in_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipients&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;NotificationConsent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call_alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# explicit no only
&lt;/span&gt;        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single &lt;code&gt;.is_(False)&lt;/code&gt; is the difference between honouring a "no" and inventing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  One OS permission, hidden behind a product choice
&lt;/h2&gt;

&lt;p&gt;There is only one notification permission at the operating-system level, and both message alerts and call ringing need it. Asking for it before the user has expressed an interest wastes the one shot you get.&lt;/p&gt;

&lt;p&gt;The sequence we use is: the user picks channels in our sheet, and only if message alerts or calls are switched on do we ask the OS. If the OS says no, the switches go back to off and the app explains it instead of pretending to save:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;pushChoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;supportsPush&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_push&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;callsChoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_calls&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;supportsPush&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pushChoice&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;callsChoice&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestOsPermission&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;granted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;pushChoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;callsChoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message after a denial is deliberately undramatic — "Notifications are blocked in system settings. You can enable them there later." — because a person who declined twice should not be scolded by an app they just installed.&lt;/p&gt;

&lt;p&gt;The other half of this is unregistering. When a device ends up with no push channel on, its push token is deleted for that account, so "off" means off rather than "off in the UI, still buzzing".&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail open where silence is dangerous
&lt;/h2&gt;

&lt;p&gt;Preference lookups can fail. Databases time out, replicas lag, a migration lands mid-request. The question is what the failure means for the user.&lt;/p&gt;

&lt;p&gt;For a marketing email, failing closed is right: if we cannot prove consent, we do not send. For an incoming call, the same logic is catastrophic. If the preference query throws and the code treats that as "not allowed", a phone stops ringing because of an unrelated hiccup. That is a missed call caused by a product decision about consent.&lt;/p&gt;

&lt;p&gt;So the call gate fails open, explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(...).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(...))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# A preference lookup must never be the reason a call stays silent.
&lt;/span&gt;    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;call-alert preference lookup failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc_info&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The asymmetry is the point, and it is worth writing down next to the code: &lt;strong&gt;consent gates should fail closed for growth and open for safety.&lt;/strong&gt; Marketing, newsletters, and re-engagement features can safely assume the answer is no. Calls, security alerts, and anything a user would call urgent cannot.&lt;/p&gt;

&lt;p&gt;One more detail from that gate: cancel pushes are still sent to a user who turned call alerts off. Otherwise a device that was already ringing when the preference landed would keep ringing with no way to dismiss it.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Not now" must not count as an answer
&lt;/h2&gt;

&lt;p&gt;The sheet is shown once, and the server records both that it was completed and which version of it was shown. That gives us a cheap way to ask again if the choices materially change: bump the version, and the prompt returns once for everyone.&lt;/p&gt;

&lt;p&gt;Dismissing is different from deciding. "Not now" closes the sheet without completing it, and the prompt can come back on a later launch — at most once per 24 hours, tracked locally per account. The cap matters: a preference that is genuinely useful deserves to be asked again later; it does not deserve to be asked on every cold start, which is how prompts become wallpaper.&lt;/p&gt;

&lt;p&gt;The sheet also refuses to cover the wrong moment. If the app opened a chat or a call from a notification or a deep link, the prompt waits for a route where nothing is in progress, and gets offered on the next launch instead. Interrupting the one thing the user opened the app for is how you teach them to dismiss you.&lt;/p&gt;

&lt;p&gt;There is a small implementation detail in the versioned-prompt model that is worth copying: the server is the source of truth for "this person has answered", and a local timestamp is only there to stop the sheet reappearing before the next launch. Clearing app data loses the local timestamp, not the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Push is per device, calls are per account
&lt;/h2&gt;

&lt;p&gt;The same sheet runs on a phone, a desktop client, and anything else signed into the account, and the four rows do not all mean the same thing on each.&lt;/p&gt;

&lt;p&gt;Message notification is per device: a phone has a token, a desktop may have none, and a preference set on the phone should not be silently rewritten by a laptop that cannot receive push at all. So the desktop hides that row and reports "no opinion", which is the &lt;code&gt;NULL&lt;/code&gt; state from earlier. The Calls choice is account-level, because a call should ring wherever the person is.&lt;/p&gt;

&lt;p&gt;Getting this wrong is easy and invisible in testing. A desktop client that sends &lt;code&gt;false&lt;/code&gt; for a channel it cannot even show looks like a clear preference in the database and a bug report in the making: "everything is off on my phone, and I never touched it".&lt;/p&gt;

&lt;h2&gt;
  
  
  Unsubscribing without logging in
&lt;/h2&gt;

&lt;p&gt;Every channel has to be as easy to leave as it was to join, and email is the one where that is hardest, because the user may be reading it in a client that has no idea who they are.&lt;/p&gt;

&lt;p&gt;Reminder emails therefore carry a signed link that needs no session, a visible unsubscribe link, and the standard one-click unsubscribe headers. Opening the link flips the flag off. It is the same column the in-app toggle writes, so the two cannot disagree, and the same consent stamp gets a new timestamp — a withdrawal is a decision too and deserves a record.&lt;/p&gt;

&lt;p&gt;In the app, Settings → Notifications exposes all four channels with the same write path as the sheet. Changing your mind does not require finding the original prompt again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;p&gt;Honesty section: opt-in rates after this change are lower than they were when the permission was requested automatically. That is the intended result, not a bug.&lt;/p&gt;

&lt;p&gt;The trade we made is a smaller list of people who actually want the alerts, in exchange for a list that means something. A push sent to a user who reflexively tapped "allow" during onboarding is worth very little; a push sent to someone who turned the switch on is worth something, and a push sent to someone who turned it off is a reason to uninstall. The same logic applies to the reminder email: it is opt-in, capped to one a week per person, and quiet for anyone who has been active.&lt;/p&gt;

&lt;p&gt;We do not have retention numbers to show for this yet — the change is days old, and claiming a measured win would be inventing one. What we can say is that the support-shaped problem is gone: there is now one place that answers "why is this app emailing me", and it is the same place that stops it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist you can copy
&lt;/h2&gt;

&lt;p&gt;If you are about to ship a notification prompt, these are the parts that mattered most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ask after the user has seen something worth notifying them about&lt;/strong&gt;, not during the first launch animation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split the purposes.&lt;/strong&gt; Message alerts, calls, reminder email, and marketing are four offers, not one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start every switch off&lt;/strong&gt;, and make "all off" a real, saved answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make consent tri-state.&lt;/strong&gt; "Never asked" must not behave like "no".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the OS last&lt;/strong&gt;, once, after a product-level yes. Revert the switch and explain if the OS declines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down which gates fail open.&lt;/strong&gt; Calls and security alerts keep working when the preference lookup breaks; marketing does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version the prompt&lt;/strong&gt; so a material change can be asked about once, without becoming a nag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make leaving as easy as joining&lt;/strong&gt;, including a link that works without a session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. It is mostly the decision to let the user answer the question instead of answering it for them, and then to build the data model that can remember the difference between "not yet" and "no".&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is a notification prompt legally required?
&lt;/h3&gt;

&lt;p&gt;Not in general, and this post is not legal advice. What is required in the EU is that consent — especially for marketing email — is freely given, specific, informed and unambiguous, which rules out pre-ticked boxes and silence. Separating the channels and defaulting them off is how you satisfy that while still asking for the alerts that make the app useful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not just ask for notifications on the first launch?
&lt;/h3&gt;

&lt;p&gt;Because the OS permission is a one-shot resource and the first launch is the worst moment to spend it. A user who has not received a message yet has no reason to say yes, and a reflexive "no" is much harder to undo than a considered "yes" is to earn.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if someone dismisses the sheet?
&lt;/h3&gt;

&lt;p&gt;Nothing is recorded as a decision. The sheet can be offered again on a later launch, at most once per 24 hours, and Settings → Notifications is always available. Only pressing Save writes the completion stamp.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do existing users lose call ringing when they update?
&lt;/h3&gt;

&lt;p&gt;No. The push and call preferences start empty (&lt;code&gt;NULL&lt;/code&gt;), which keeps the previous behaviour until the person actually answers the prompt. Only an explicit "off" suppresses anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does turning off message notifications still leave the account reachable?
&lt;/h3&gt;

&lt;p&gt;Yes. In-app calls still arrive while the app is open and connected, and unread counts still work. The switch controls push delivery, not the account.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does the reminder email contain?
&lt;/h3&gt;

&lt;p&gt;Only a nudge that messages are waiting, with a link back to the app and a one-click unsubscribe. It never includes message content, and for encrypted chats it does not name the sender or the conversation either.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Get Folcadi&lt;/strong&gt; — a language-exchange messenger with live translation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Official website:&lt;/strong&gt; &lt;a href="https://folcadi.com" rel="noopener noreferrer"&gt;folcadi.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.folcadi.app" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/folcadi-releases/releases/latest" rel="noopener noreferrer"&gt;.zip or installer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/folcadi-releases/releases/latest" rel="noopener noreferrer"&gt;.deb or portable tarball&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Articles:&lt;/strong&gt; &lt;a href="https://frankmilvus.github.io/folcadi-articles/" rel="noopener noreferrer"&gt;write-ups and covers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram:&lt;/strong&gt; &lt;a href="https://t.me/frank_says" rel="noopener noreferrer"&gt;@frank_says&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>ux</category>
      <category>privacy</category>
      <category>mobile</category>
    </item>
    <item>
      <title>A Small App for Practicing Mental Math 🧠</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Sun, 01 Nov 2026 01:40:26 +0000</pubDate>
      <link>https://dev.to/frankmilvus/a-small-app-for-practicing-mental-math-561n</link>
      <guid>https://dev.to/frankmilvus/a-small-app-for-practicing-mental-math-561n</guid>
      <description>&lt;p&gt;I created &lt;strong&gt;NeuroCalT+ Mental Math&lt;/strong&gt; to help me practice the &lt;strong&gt;Trachtenberg system&lt;/strong&gt; of mental calculation.&lt;/p&gt;

&lt;p&gt;The idea is simple: this app is not designed to teach you the entire Trachtenberg system or turn you into a fast calculator overnight. It is primarily a &lt;strong&gt;practice tool&lt;/strong&gt;, with explanations of the methods to help you understand how they work.&lt;/p&gt;

&lt;p&gt;I don't always have time to follow the app's progress or see how people are using it. Life gets busy, and sometimes side projects have to wait.&lt;/p&gt;

&lt;p&gt;But it's always a pleasure to open Google Play Console and discover that someone has installed the app or found it useful. Even a few downloads are a nice reminder that something you created for yourself might help someone else, too.&lt;/p&gt;

&lt;p&gt;Thank you to everyone who has given it a try!&lt;/p&gt;

&lt;p&gt;If you're interested in practicing mental math, you can find it here:&lt;/p&gt;

&lt;p&gt;📱 &lt;a href="https://play.google.com/store/apps/details?id=com.milvus.grema" rel="noopener noreferrer"&gt;NeuroCalT+ Mental Math on Google Play&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Sometimes, the best part of building something isn't watching the numbers grow — it's knowing that someone out there might find your work useful.&lt;/p&gt;

</description>
      <category>coding</category>
      <category>resources</category>
      <category>learning</category>
      <category>dart</category>
    </item>
    <item>
      <title>Keyboard navigation in Flutter on Linux: one modifier, cursors</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Sat, 31 Oct 2026 06:17:13 +0000</pubDate>
      <link>https://dev.to/frankmilvus/keyboard-navigation-in-flutter-on-linux-one-modifier-cursors-3kj7</link>
      <guid>https://dev.to/frankmilvus/keyboard-navigation-in-flutter-on-linux-one-modifier-cursors-3kj7</guid>
      <description>&lt;p&gt;Keyboard navigation in Flutter sounds like a solved problem until you try to add it to a real app. The framework gives you &lt;code&gt;Shortcuts&lt;/code&gt;, &lt;code&gt;Actions&lt;/code&gt;, and a focus tree, and on a form with a handful of fields that is genuinely enough. Then you open a chat, and the thing you want to activate is a message bubble that is not a focusable control, the list under it is reversed, an overlay is anchored to a row that may scroll away, and the user may be typing at the same time.&lt;/p&gt;

&lt;p&gt;That is the situation in Folcadi, a language-exchange messenger with a Flutter client. This post is not a celebration of complete keyboard parity. It is a description of the model we settled on, the two rules that keep it honest, and the parts that are still mouse-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first attempt was a pile of letters
&lt;/h2&gt;

&lt;p&gt;The earliest version was the obvious one. Each screen got letter chords: &lt;code&gt;Alt+S&lt;/code&gt; for Settings, &lt;code&gt;Alt+M&lt;/code&gt; for Saved Messages, &lt;code&gt;Alt+F&lt;/code&gt; for Feedback, and so on. It worked, and it was discoverable, because the tiles could show their own hint.&lt;/p&gt;

&lt;p&gt;It also did not scale. Every new row wanted a letter, the letters started to collide across screens, and the help text grew into a list nobody could remember. Worse, the model implied that the keyboard was a second set of buttons rather than a way to move through the interface.&lt;/p&gt;

&lt;p&gt;The version that survived is smaller:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one modifier, &lt;code&gt;Alt&lt;/code&gt; by default, with &lt;code&gt;Ctrl&lt;/code&gt; as an option for keyboards or browsers that claim &lt;code&gt;Alt&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;plain keys stay plain, so &lt;code&gt;j&lt;/code&gt;, &lt;code&gt;k&lt;/code&gt;, &lt;code&gt;r&lt;/code&gt;, and &lt;code&gt;d&lt;/code&gt; keep typing normally;&lt;/li&gt;
&lt;li&gt;navigation is a cursor that moves and activates, not a shortcut per item.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the whole design. Lists, menus, confirmation dialogs, and the chat message list all use the same interaction: move the highlight, press Enter, press Escape to go back. New rows do not need a new letter; they become reachable the moment they are added to a list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolve the modifier at match time
&lt;/h2&gt;

&lt;p&gt;The modifier is a user setting, not a compile-time constant. Users pick &lt;code&gt;Alt&lt;/code&gt; or &lt;code&gt;Ctrl&lt;/code&gt; in Settings → Shortcuts, and the choice is stored locally and synced with the rest of their settings. Handlers read it when a key arrives, so changing it takes effect immediately, without rebuilding every screen.&lt;/p&gt;

&lt;p&gt;The helper is deliberately small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;_useCtrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;hotkeyModifierNotifier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;HotkeyModifier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;KeyboardShortcut&lt;/span&gt; &lt;span class="nf"&gt;mainHotkey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LogicalKeyboardKey&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;KeyboardShortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;key:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;alt:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;_useCtrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;control:&lt;/span&gt; &lt;span class="n"&gt;_useCtrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;matchesMainHotkey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KeyEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LogicalKeyboardKey&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;mainHotkey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else — handlers, help labels, the in-app cheat sheet — goes through helpers like these. When a chord is displayed, it is formatted from the same source of truth, so switching to &lt;code&gt;Ctrl&lt;/code&gt; changes the labels too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cursors are the unit of navigation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;KeyboardListCursor&lt;/code&gt; is the reusable piece. A page supplies the number of rows, an activation callback, and a builder that highlights the row at the current index. The widget owns the index, the raw key handler, and scrolling the highlighted row into view.&lt;/p&gt;

&lt;p&gt;The important part is not the widget. It is that the row is built as a pair — the widget and the action — and the tap handler and the Enter handler call the same function. The keyboard path cannot drift from the touch path, because there is only one path.&lt;/p&gt;

&lt;p&gt;The same cursor covers the More tab, Settings, contacts, notifications, pinned messages, blocked users, the account switcher, and the recipient pickers for forwarding, sharing, and adding members. Confirmation dialogs follow the same rule: &lt;code&gt;H&lt;/code&gt;/&lt;code&gt;K&lt;/code&gt; and &lt;code&gt;L&lt;/code&gt;/&lt;code&gt;J&lt;/code&gt; move between the buttons, Enter runs the focused one, Escape cancels. There are no &lt;code&gt;Y&lt;/code&gt;/&lt;code&gt;N&lt;/code&gt; letter chords, because a dialog is just another two-row list.&lt;/p&gt;

&lt;p&gt;Pages that have nothing to activate — Help, profiles, device settings, a long settings sub-page — get a scroll scope instead: &lt;code&gt;J&lt;/code&gt;/&lt;code&gt;K&lt;/code&gt; for a small step, the arrow keys for half a page. A page gets a cursor or a scroll scope, never both. Menus need a highlight; switches and sliders need movement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chat is where the model gets tested
&lt;/h2&gt;

&lt;p&gt;The chat was the hardest surface. It has bubbles rather than rows, a reversed scroll view, media that can be tall, and a composer that wants every plain letter.&lt;/p&gt;

&lt;p&gt;The message cursor moves with &lt;code&gt;J&lt;/code&gt;/&lt;code&gt;K&lt;/code&gt;. The first press lands on the newest message, movement clamps at both ends, and date separators and system chips are skipped. &lt;code&gt;Space&lt;/code&gt; opens the actions menu for the highlighted message, and that menu is a two-dimensional grid: &lt;code&gt;H&lt;/code&gt;/&lt;code&gt;L&lt;/code&gt; move across, &lt;code&gt;J&lt;/code&gt;/&lt;code&gt;K&lt;/code&gt; move down, Enter activates. The quick-emoji row is row zero, so reactions are reachable without a detour through the action list.&lt;/p&gt;

&lt;p&gt;The most recent change removed a round trip. &lt;code&gt;R&lt;/code&gt;, &lt;code&gt;E&lt;/code&gt;, and &lt;code&gt;D&lt;/code&gt; now act directly on the highlighted message: reply, edit, delete. The menu still offers the same actions, and the eligibility rules are shared with the menu rather than duplicated, so a message that cannot be edited cannot be edited by either path. When the highlighted message is not yours, the keys do nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;R&lt;/code&gt; was the interesting case, because it already meant "record a voice message" in the composer. Two meanings for one key is how a keyboard model rots. The fix was to make the two modes mutually exclusive: moving the cursor blurs the composer, and focusing the composer — by tap, by &lt;code&gt;I&lt;/code&gt;, or by choosing Reply or Edit — drops the cursor. With a highlight, &lt;code&gt;R&lt;/code&gt; replies; with no highlight, &lt;code&gt;R&lt;/code&gt; records. The state of the screen tells you which one you will get.&lt;/p&gt;

&lt;p&gt;Half-page scrolling moved to the arrows at the same time. &lt;code&gt;D&lt;/code&gt; and &lt;code&gt;U&lt;/code&gt; had been the page-scroll chords, which was fine until &lt;code&gt;D&lt;/code&gt; became Delete on the highlighted message. The arrows were unclaimed and are easier to explain. The chat's list is reversed, so the scroll scope flips the sign by axis direction: "down" always means visually down.&lt;/p&gt;

&lt;p&gt;The chat also binds &lt;code&gt;F&lt;/code&gt; for in-chat search, &lt;code&gt;C&lt;/code&gt; for the call chooser, and &lt;code&gt;B&lt;/code&gt; for the translation mode. Each calls the exact callback the app-bar button calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Escape should cancel before it navigates
&lt;/h2&gt;

&lt;p&gt;The back button is destructive if it leaves the screen while something is half-finished. If you have started a reply, staged a photo, or begun a recording, the first Escape should undo that, not walk you out of the conversation.&lt;/p&gt;

&lt;p&gt;The chat's back handler now vetoes navigation while any transient state is active, and unwinds it in a fixed order: message actions overlay, multi-select, search panel, app-bar menu, attach bar, voice recording, edit mode, reply bar, staged image or album, expanded editor. Only when none of those is active does Escape leave the chat.&lt;/p&gt;

&lt;p&gt;That one change made the rest of the hotkeys feel safe. Escape is "cancel" for a reply, an edit, or a recording, and Escape is still "back" when there is nothing to cancel. The rule is easy to state and, once implemented, easy to keep — the new &lt;code&gt;E&lt;/code&gt; and &lt;code&gt;D&lt;/code&gt; actions inherited it for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The focus problem nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Flutter's &lt;code&gt;HardwareKeyboard&lt;/code&gt; calls every registered raw handler before the focus system gets the event. Returning &lt;code&gt;true&lt;/code&gt; from a handler does not stop the focus tree from dispatching it afterwards.&lt;/p&gt;

&lt;p&gt;This is not a theory. In a review pass over an earlier keyboard sweep, we found that pressing Enter with a cursor set could run the highlighted row &lt;em&gt;and&lt;/em&gt; a button that happened to own focus — for example, opening two stacked confirmations in the account switcher. The fix is a rule, not a workaround: if a control inside the same route already owns focus, the cursor yields and Enter belongs to that control. When nothing in the route owns focus, the cursor takes it.&lt;/p&gt;

&lt;p&gt;The same property — every raw handler runs — creates the second rule: one chord, one owner. Two features must never bind the same combination while both are live. A sweep caught exactly that when &lt;code&gt;Alt+X&lt;/code&gt; was bound both to "remove this account" in the switcher and to "ignore this call" on the incoming-call ring. Ignore moved to &lt;code&gt;Alt+N&lt;/code&gt;. The ring's keys also avoid the letters used by the cursors underneath, because a ring is mounted above every route and shares the focus scope of whatever page is visible.&lt;/p&gt;

&lt;p&gt;These are the kinds of bugs that pass a clean analyzer and a green test suite. The behavioural review found them; the tests were written afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this was tested on
&lt;/h2&gt;

&lt;p&gt;Everything described here has been developed and tested on &lt;strong&gt;Arch Linux with the niri compositor&lt;/strong&gt;. The key handling itself is ordinary Flutter — &lt;code&gt;HardwareKeyboard&lt;/code&gt;, &lt;code&gt;ShortcutScope&lt;/code&gt;, and raw handlers, with no compositor-specific code — so there is no reason it should be Arch- or niri-specific. But that is the only combination that has been exercised end to end so far.&lt;/p&gt;

&lt;p&gt;Other Linux desktops, X11 sessions, Windows, macOS, and Android with a physical keyboard are expected to behave the same, and the widget tests run on Flutter's standard test platform. They are not verified on real hardware yet. If you try the app on another setup and a chord is inert, fires twice, or fights a desktop shortcut, that report is genuinely useful — it is the kind of gap this post is trying to be honest about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not done yet
&lt;/h2&gt;

&lt;p&gt;It would be easy to write this post and imply that Folcadi now works fully without a pointer. It does not, and I would rather say so plainly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Some surfaces are still pointer-first.&lt;/strong&gt; Gestures that have no obvious key equivalent — several drag and swipe interactions, for example — are reachable only by touch or mouse today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every overlay is fully navigable.&lt;/strong&gt; The main ones are; a few secondary surfaces still need a pointer or Tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no complete focus traversal.&lt;/strong&gt; The app has a cursor model, not a full tab-order contract. A screen reader user and a keyboard-only user need different things, and the accessibility work is not the same project as the hotkey work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web is the weak platform.&lt;/strong&gt; Browsers claim several modifier combinations before the app sees them. The hotkeys are designed for desktop and for Android with a physical keyboard, including environments that map a PC keyboard into Android; on the web they are best-effort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile is for external keyboards.&lt;/strong&gt; The chords assume a hardware keyboard. On a phone with only a soft keyboard, the touch UI remains the primary interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only one desktop setup is verified.&lt;/strong&gt; Development and testing happen on Arch Linux with niri; other desktops and operating systems should work by construction but have not been exercised on real hardware.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation drifts.&lt;/strong&gt; Every new surface has to register its groups with the cheat sheet and the in-app help. If that step is skipped, the feature exists but is undiscoverable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The direction is to keep closing gaps in that order: make the common flows complete, make the state cancellation predictable, and only then chase breadth.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to discover what works
&lt;/h2&gt;

&lt;p&gt;Press &lt;code&gt;F1&lt;/code&gt; anywhere. The cheat sheet is assembled from the scopes that are actually active on the current screen, so a plain page shows a few rows and a chat adds its own. The same chords are documented under More → Help, and the labels follow whichever modifier you chose.&lt;/p&gt;

&lt;p&gt;If you use a keyboard with the app and find a place where you reach for the mouse, that is useful feedback. The gaps above are known; the one you hit is the one worth fixing next.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Which platforms has this been tested on?
&lt;/h3&gt;

&lt;p&gt;Arch Linux with niri. The handlers are plain Flutter and the widget tests run on the standard test platform, so other Linux desktops and Windows or macOS should work, but they have not been verified on real hardware yet. Web is limited by browser shortcuts, and mobile without an external keyboard still relies on touch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use Ctrl instead of Alt?
&lt;/h3&gt;

&lt;p&gt;Yes. Settings → Shortcuts offers both. The choice is stored locally and synced like other settings, and every hint and help label updates to match.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not use plain letters, like a terminal?
&lt;/h3&gt;

&lt;p&gt;Because the app has text fields everywhere. A chat composer, a search box, and a filter field all need ordinary letters. Requiring a modifier keeps typing and navigation from competing for the same keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I cancel something I started with the keyboard?
&lt;/h3&gt;

&lt;p&gt;Press Escape. It unwinds the current transient state first — a reply, an edit, a recording, a selection — and only leaves the screen when there is nothing left to cancel.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Get Folcadi&lt;/strong&gt; — a language-exchange messenger with live translation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Official website:&lt;/strong&gt; &lt;a href="https://folcadi.com" rel="noopener noreferrer"&gt;folcadi.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.folcadi.app" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/folcadi-releases/releases/latest" rel="noopener noreferrer"&gt;.zip or installer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/folcadi-releases/releases/latest" rel="noopener noreferrer"&gt;.deb or portable tarball&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Articles:&lt;/strong&gt; &lt;a href="https://frankmilvus.github.io/folcadi-articles/" rel="noopener noreferrer"&gt;write-ups and covers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram:&lt;/strong&gt; &lt;a href="https://t.me/frank_says" rel="noopener noreferrer"&gt;@frank_says&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>linux</category>
      <category>a11y</category>
      <category>folcadi</category>
    </item>
    <item>
      <title>GwinG is now Folcadi</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Thu, 29 Oct 2026 22:24:05 +0000</pubDate>
      <link>https://dev.to/frankmilvus/gwing-is-now-folcadi-244f</link>
      <guid>https://dev.to/frankmilvus/gwing-is-now-folcadi-244f</guid>
      <description>&lt;p&gt;The app is now called &lt;strong&gt;Folcadi&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The old name was &lt;strong&gt;GwinG&lt;/strong&gt;. Everything else is the same: your account, your conversations, your settings, and the people you talk to.&lt;/p&gt;

&lt;p&gt;The new name is rolling out across the app, the website, and the desktop builds. If a notification or a window title still shows the old name, update to the latest build.&lt;/p&gt;

&lt;p&gt;That's it.&lt;/p&gt;

&lt;p&gt;Welcome to Folcadi.&lt;/p&gt;

&lt;p&gt;— the Folcadi team&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Get Folcadi&lt;/strong&gt; — a language-exchange messenger with live translation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Official website:&lt;/strong&gt; &lt;a href="https://folcadi.com" rel="noopener noreferrer"&gt;folcadi.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.milvus.tongue" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/tonGue-releases/releases/latest" rel="noopener noreferrer"&gt;.zip or installer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/tonGue-releases/releases/latest" rel="noopener noreferrer"&gt;.deb or portable tarball&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Articles:&lt;/strong&gt; &lt;a href="https://frankmilvus.github.io/tongues-articles/" rel="noopener noreferrer"&gt;write-ups and covers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram:&lt;/strong&gt; &lt;a href="https://t.me/frank_says" rel="noopener noreferrer"&gt;@frank_says&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>showdev</category>
      <category>languageexchange</category>
      <category>folcadi</category>
    </item>
    <item>
      <title>Flutter WebRTC calls that never connected: three bugs, three fixes</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:46:14 +0000</pubDate>
      <link>https://dev.to/frankmilvus/flutter-webrtc-calls-that-never-connected-three-bugs-three-fixes-a59</link>
      <guid>https://dev.to/frankmilvus/flutter-webrtc-calls-that-never-connected-three-bugs-three-fixes-a59</guid>
      <description>&lt;p&gt;For two weeks, every 1:1 audio and video call in our Flutter app failed the same boring way: the caller saw "Calling…", the screen flipped to "Ended" about a second later, and the callee's phone never rang. The client logs looked fine. The peer connection reached &lt;code&gt;have-local-offer&lt;/code&gt; and then closed.&lt;/p&gt;

&lt;p&gt;The temptation is to pick one suspect and start there. We spent the first evening on the relay server, which was genuinely broken — and fixing it changed nothing, because two other bugs sat in front of it. This is the story of all three, plus the small traps that were still waiting after them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom: &lt;code&gt;have-local-offer&lt;/code&gt;, then silence
&lt;/h2&gt;

&lt;p&gt;Worth stating precisely, because it narrows the search:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The app created an offer, set it as its local description, and sent it.&lt;/li&gt;
&lt;li&gt;The backend accepted the WebSocket message without an error.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;answer&lt;/code&gt; ever arrived. After a timeout, the client tore the call down.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That rules out most of WebRTC theory — codecs, SDP munging, permissions — and leaves one question: did the offer actually reach the other side?&lt;/p&gt;

&lt;p&gt;It did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: the signalling key pointed at a row nobody created
&lt;/h2&gt;

&lt;p&gt;The app signals calls over the WebSocket. Each event carries a &lt;code&gt;call_id&lt;/code&gt;, and the backend looked up a &lt;code&gt;CallSession&lt;/code&gt; by that id before relaying.&lt;/p&gt;

&lt;p&gt;The problem is what &lt;code&gt;call_id&lt;/code&gt; contains: the &lt;strong&gt;conversation&lt;/strong&gt; id. The client never creates a call session at all — the table exists, the endpoints exist, and nothing calls them. So the lookup returned &lt;code&gt;None&lt;/code&gt; for every offer, answer and ICE candidate, and the handler returned early:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Call 42 not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every event was dropped. The call closed immediately after &lt;code&gt;have-local-offer&lt;/code&gt;, which is exactly what the symptom looked like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; was to stop looking for a session and route by membership instead: resolve the participants of the conversation, reject a sender who is not a member, and relay the event to everyone else. The wire format did not change. &lt;code&gt;call_id&lt;/code&gt; still means the conversation id; the backend re-prefixes the event type (&lt;code&gt;offer&lt;/code&gt; → &lt;code&gt;call_offer&lt;/code&gt;) and relays any type verbatim, including ones it has never heard of.&lt;/p&gt;

&lt;p&gt;Two side benefits fell out of the change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A new signalling event no longer needs a backend deploy to work.&lt;/li&gt;
&lt;li&gt;The dead &lt;code&gt;CallSession&lt;/code&gt;/&lt;code&gt;CallParticipant&lt;/code&gt; models became obvious — including an endpoint that used a model it never imported, which only surfaced as a &lt;code&gt;NameError&lt;/code&gt;. A test now pins that path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; when a lookup fails on an id, check whether both sides agree on &lt;em&gt;what the id identifies&lt;/em&gt;. &lt;code&gt;call_id&lt;/code&gt; was a naming lie, and it cost a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: a relay only the server could reach
&lt;/h2&gt;

&lt;p&gt;With routing fixed, calls still failed — but now only sometimes. Office Wi‑Fi worked. Mobile networks did not. That is the classic signature of symmetric NAT: direct peer-to-peer connectivity fails, and everything has to go through a relay.&lt;/p&gt;

&lt;p&gt;We had a relay. It was simply unreachable.&lt;/p&gt;

&lt;p&gt;The TURN server ran on a machine behind NAT, published through a port-forwarding tunnel. Two things were wrong with that, and only one is obvious:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The relay advertised its own &lt;strong&gt;container address&lt;/strong&gt; as the relay candidate, so clients were handed an address that does not exist outside that host.&lt;/li&gt;
&lt;li&gt;Even with the address fixed, a single forwarded port cannot carry TURN. A relay is not one connection: each allocation gets its own UDP port for the peer. The tunnel maps one port; the relay needs a range. And STUN through that tunnel reports the tunnel as the client, so the whole candidate dance is meaningless.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; was to move the relay to a host that owns a public IP and configure it for the outside world. The settings that mattered, with the real values redacted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;listening-port&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3478&lt;/span&gt;
&lt;span class="py"&gt;external-ip&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;public-ip&amp;gt;         # without this: a private address is advertised&lt;/span&gt;
&lt;span class="py"&gt;min-port&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;first-relay-port&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;max-port&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;last-relay-port&amp;gt;      # a dedicated UDP relay range, firewall-open&lt;/span&gt;
&lt;span class="py"&gt;denied-peer-ip&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;private-range&amp;gt;  # one line per range: loopback, link-local, RFC 1918&lt;/span&gt;
&lt;span class="na"&gt;no-multicast-peers&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three operational details that are easy to miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The DNS record must be DNS-only.&lt;/strong&gt; If the relay hostname sits behind an HTTP proxy or CDN, the proxy answers the UDP/TCP traffic and the relay never sees it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open the whole relay range in the host firewall&lt;/strong&gt;, not just the listening port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the kernel's ephemeral port range away from the relay range.&lt;/strong&gt; Otherwise the kernel can already hold a port the relay wants to bind, and allocations fail intermittently — the worst kind of failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The relay is also restricted so it cannot be used as a door into the host's private networks (the &lt;code&gt;denied-peer-ip&lt;/code&gt; lines above). A relay reachable from the internet is a feature; a relay that will happily forward to the host's own loopback sockets is a vulnerability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; "peer-to-peer works on my network" is not a test. Try a mobile network, and treat a missing relay as an untested code path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: the app stops listening when it goes to the background
&lt;/h2&gt;

&lt;p&gt;Now calls connected — while both apps were open. Minimise one, lock the phone, and the call again reached nobody.&lt;/p&gt;

&lt;p&gt;This one is architectural, not a typo. The app closes its WebSocket when it goes to the background, deliberately: an idle socket on a sleeping phone is a battery and reconnection problem. But a call offer is a one-shot event. Sending it to a socket that is closed means the offer is simply gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; the backend now sends a &lt;strong&gt;data-only push&lt;/strong&gt; whenever it relays an offer, and the app's background isolate shows a &lt;strong&gt;full-screen call notification&lt;/strong&gt; with a looping ringtone, a vibration pattern, and Accept / Ignore / Decline actions. Accept launches the app and auto-answers; the pending call is parked in shared preferences so the UI isolate can adopt it on cold start or resume. There is no foreground service and no native audio code: the notification &lt;em&gt;is&lt;/em&gt; the ring.&lt;/p&gt;

&lt;p&gt;Three details turned out to matter more than the notification itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The push carries no SDP — and it should not.&lt;/strong&gt; By the time the callee wakes up, the caller's ICE candidates have already been sent over a socket that was closed. The media path is broken whether or not the callee has the offer. So instead of squeezing an offer through a push, the callee sends an &lt;code&gt;offer_request&lt;/code&gt; on accept, and the caller builds a fresh offer with &lt;code&gt;restartIce()&lt;/code&gt;. A bare re-offer would reuse the already-converged ICE credentials and produce &lt;strong&gt;no new candidates&lt;/strong&gt;, which is a confusing way to fail. The request also restarts the caller's ring timeout, so a slow wake-up does not look like a hang-up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decline is headless.&lt;/strong&gt; The decline action runs in the background isolate, which has no WebSocket. It refuses the call over HTTP, and the server reuses the same relay path it would have used for a socket event. It also echoes a reject back to the declining device, so a ring that is still visible in the running app clears too. Ignore is also headless but signals nothing — it silences this device and lets the caller ring out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The push must never block signalling.&lt;/strong&gt; Pushes run on the caller's own WebSocket loop, right after the offer is relayed, and the ICE candidates are queued behind them. So the push is time-boxed to 1.5 seconds: an unreachable push service may delay the ring, but it must not stop the call from connecting.&lt;/p&gt;

&lt;p&gt;Two Android details, since the ring lives in a notification channel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Channel properties (sound, vibration, importance) are &lt;strong&gt;frozen at creation&lt;/strong&gt;. Changing the ringtone therefore needs a new channel id, so the app selects one of a small family of channels based on the user's sound/vibration preference.&lt;/li&gt;
&lt;li&gt;A channel's vibration pattern plays &lt;strong&gt;once&lt;/strong&gt;, and &lt;code&gt;FLAG_INSISTENT&lt;/code&gt; reliably repeats the &lt;em&gt;sound&lt;/em&gt;, not the vibration. The pattern has to span the whole ring.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one lock-screen rule: waking the screen and drawing above the keyguard is held &lt;strong&gt;only while a call exists&lt;/strong&gt;, at runtime. Declaring it statically on the activity looks simpler and is worse — every launch then surfaces over the lock screen, which is how users end up staring at their PIN pad after a call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three smaller traps we hit afterwards
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ICE candidates that arrive before the remote description.&lt;/strong&gt; Candidates can only be applied once the peer connection has a remote description (the offer on the callee, the answer on the caller). Both sides routinely send candidates before they have it. Applying one too early is rejected by libwebrtc and silently lost — the call "connects", but media never flows. We buffer inbound candidates until the description exists, then flush.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential-less ICE entries and JSON &lt;code&gt;null&lt;/code&gt;.&lt;/strong&gt; If a STUN entry is serialised with &lt;code&gt;"username": null, "credential": null&lt;/code&gt;, some platforms treat the server as &lt;em&gt;credentialed&lt;/em&gt; and hand it to the wrong initialiser. The fix is to omit the keys entirely. This stays invisible until you try a platform you did not test on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Devices that cannot capture video.&lt;/strong&gt; One of our test devices exposes a camera that the WebRTC library fails to open with a &lt;strong&gt;fatal native error&lt;/strong&gt; — not an exception Dart can catch, so the process dumps traces and it reads as a crash. Enumeration does not help: the camera is listed. We now combine identity-based detection, an explicit user override (Always on / Automatic / Always off), and a bounded fallback that degrades a call to audio instead of aborting it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why we were debugging blind
&lt;/h2&gt;

&lt;p&gt;None of the above was findable from the app alone, because of a one-line mistake: the application root logger had &lt;strong&gt;no handler&lt;/strong&gt;. Every &lt;code&gt;logger.info&lt;/code&gt; in the backend — including the "call not found" line that would have ended the investigation in minutes — was silently discarded. The framework was happy to run without one.&lt;/p&gt;

&lt;p&gt;Attaching a stdout handler immediately produced the diagnosis. Now the call handler logs one line per relayed event, with ICE candidates at DEBUG so they cannot bury the offer/answer/end lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;call_offer conversation=42 from=1 to=[2]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; before adding logging to a subsystem, check that the logging you already have is not being thrown away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like when it works
&lt;/h2&gt;

&lt;p&gt;Audio and video calls now connect end to end: peer-to-peer when the network allows, relayed when it does not, and ringing on a locked phone. If ICE drops mid-call — a Wi‑Fi to cellular handover, for example — the caller renegotiates with a fresh offer, and only the caller ever offers, so the two sides cannot both renegotiate at once. A watchdog rebuilds a first attempt that sits in &lt;code&gt;connecting&lt;/code&gt; too long, which is the difference between "video shows up in two seconds" and "the first call takes forever". We added &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-12-small-polish-connection-status-and-call-end-card.html" rel="noopener noreferrer"&gt;the call-end card&lt;/a&gt; earlier in the same series of polish work, and it finally has something to show.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is still missing
&lt;/h2&gt;

&lt;p&gt;Being honest about the edges is part of the design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Group calls.&lt;/strong&gt; The relay already fans out to every conversation member, but the client is 1:1 and real group calling needs a different media server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS background ringing.&lt;/strong&gt; The push is still a background push there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ephemeral relay credentials.&lt;/strong&gt; The credential is static today; short-lived credentials are the next improvement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real audio-route switch for speaker.&lt;/strong&gt; The toggle is cosmetic on some devices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-device calls.&lt;/strong&gt; Every device should ring and the first answer should win. That is the next post.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Why does a re-offer need &lt;code&gt;restartIce()&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Because a new offer alone does not re-gather candidates. The ICE credentials are already converged, so the offer reuses them and produces no new candidates — while the callee still has none of the old ones. &lt;code&gt;restartIce()&lt;/code&gt; forces a fresh gathering round. See the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Session_lifetime" rel="noopener noreferrer"&gt;WebRTC session lifetime notes&lt;/a&gt; and &lt;a href="https://datatracker.ietf.org/doc/html/rfc8656" rel="noopener noreferrer"&gt;RFC 8656&lt;/a&gt; for the relay side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why serve the ICE configuration from the backend instead of embedding it?
&lt;/h3&gt;

&lt;p&gt;A credential compiled into the app cannot be rotated, and it can drift from what the relay actually accepts. Serving it means the server is the single source of truth, and the app caches it for the returned TTL. Configuration trouble then degrades a call to peer-to-peer instead of breaking it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why close the WebSocket in the background instead of keeping it open?
&lt;/h3&gt;

&lt;p&gt;An idle socket on a sleeping phone is a battery and reconnection liability, and it does not reliably survive Android's background restrictions anyway. A push for the one event that must not be missed is a much smaller contract.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do these fixes apply to group calls?
&lt;/h3&gt;

&lt;p&gt;Partly. The signalling is already conversation-based, so relaying to N participants works. What does not work is the media topology: N participants using N peer connections does not scale, and the client is written for 1:1. That needs a different media server and a different post.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you make the full-screen ring appear on a locked phone?
&lt;/h3&gt;

&lt;p&gt;A full-screen intent on a high-importance notification channel, plus the runtime lock-screen hold described above. Android grants and can revoke the full-screen intent permission, so the code degrades to a heads-up card with the same actions instead of failing. The &lt;a href="https://developer.android.com/develop/ui/views/notifications/time-sensitive" rel="noopener noreferrer"&gt;Android notification guidance&lt;/a&gt; is a good starting point.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Get GwinG&lt;/strong&gt; — a language-exchange messenger with live translation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.milvus.tongue" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/tonGue-releases/releases/latest" rel="noopener noreferrer"&gt;.zip or installer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/tonGue-releases/releases/latest" rel="noopener noreferrer"&gt;.deb or portable tarball&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Articles:&lt;/strong&gt; &lt;a href="https://frankmilvus.github.io/tongues-articles/" rel="noopener noreferrer"&gt;write-ups and covers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram:&lt;/strong&gt; &lt;a href="https://t.me/frank_says" rel="noopener noreferrer"&gt;@frank_says&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>webrtc</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>It is time for Linux.</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:39:49 +0000</pubDate>
      <link>https://dev.to/frankmilvus/it-is-time-for-linux-5c79</link>
      <guid>https://dev.to/frankmilvus/it-is-time-for-linux-5c79</guid>
      <description>&lt;p&gt;Switching from Windows to Linux used to come with a steep learning curve. Figuring out different package managers, command-line flags, or manual installers just to get your daily apps running kept many people from making the leap.&lt;/p&gt;

&lt;p&gt;That time is over.&lt;/p&gt;

&lt;p&gt;With modern AI agents and terminal tools like OpenCode, setting up a new Linux environment is effortless. Want to install your favorite web browser, a chat messenger, or productivity tools? Instead of guessing whether to use apt, snap, or flatpak, you just tell your AI assistant what you want to install—and it handles the syntax and execution for you.&lt;/p&gt;

&lt;p&gt;Here is why moving to Linux now makes sense:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Zero app installation friction: Natural language replaces tricky package manager commands.

Instant daily setup: Get your favorite browsers, messengers, and tools running in seconds.

Complete freedom: Enjoy the speed, security, and privacy of Linux without the terminal headaches.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;If you’ve been hesitating to leave Windows, the barrier is gone. Linux is ready when you are.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>agents</category>
      <category>automation</category>
      <category>ai</category>
    </item>
    <item>
      <title>Vibe-decking: how I use AI to build Anki decks for languages</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Sun, 25 Oct 2026 13:10:33 +0000</pubDate>
      <link>https://dev.to/frankmilvus/vibe-decking-how-i-use-ai-to-build-anki-decks-for-languages-3hcf</link>
      <guid>https://dev.to/frankmilvus/vibe-decking-how-i-use-ai-to-build-anki-decks-for-languages-3hcf</guid>
      <description>&lt;p&gt;I used AI to add 327 Korean grammar and reading cards to my Anki collection. I also used it to update my vocabulary decks without creating a pile of duplicates.&lt;/p&gt;

&lt;p&gt;You do not need to know how to code to do this. You do not need a special app, an add-on, or a subscription to a flashcard service. You need three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the AI chat you already use (ChatGPT, Claude, Gemini — whichever you like),&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apps.ankiweb.net/" rel="noopener noreferrer"&gt;Anki&lt;/a&gt;, the free flashcard app,&lt;/li&gt;
&lt;li&gt;something to learn from: a textbook, a course handout, a grammar reference, or your own existing deck.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest is a repeatable loop: &lt;strong&gt;describe the deck, let the AI draft it, review it like an editor, import it.&lt;/strong&gt; I call it vibe-decking, because it is the same idea as vibe coding, applied to language learning.&lt;/p&gt;

&lt;h2&gt;
  
  
  What vibe-decking has in common with vibe coding
&lt;/h2&gt;

&lt;p&gt;Vibe coding is when you describe an app to an AI, it writes the code, you run it, and you keep fixing what breaks. You can get surprisingly far without being a programmer.&lt;/p&gt;

&lt;p&gt;Vibe-decking is the same loop:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vibe coding&lt;/th&gt;
&lt;th&gt;Vibe-decking&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;You describe the app you want.&lt;/td&gt;
&lt;td&gt;You describe the deck you want.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The AI writes the code.&lt;/td&gt;
&lt;td&gt;The AI writes the cards.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You run the app and fix errors.&lt;/td&gt;
&lt;td&gt;You study the cards and fix mistakes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Some mistakes show up immediately.&lt;/td&gt;
&lt;td&gt;Nothing catches problems for you.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the important one. Code usually fails loudly: the app crashes, the button does nothing, the page is blank. A bad flashcard fails silently. It looks perfect, you trust it, and three weeks later you discover you have been practising a wrong translation or a grammar pattern that nobody actually uses.&lt;/p&gt;

&lt;p&gt;So the rule is simple: &lt;strong&gt;the AI writes the first draft, you are the editor.&lt;/strong&gt; The time saving is real, but it comes from skipping the blank page — not from skipping the review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Start from something real
&lt;/h2&gt;

&lt;p&gt;Start with one source. It can be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a textbook chapter,&lt;/li&gt;
&lt;li&gt;a grammar reference,&lt;/li&gt;
&lt;li&gt;a course handout,&lt;/li&gt;
&lt;li&gt;a list of words you want to learn,&lt;/li&gt;
&lt;li&gt;or your existing Anki deck, if the goal is to improve it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the Korean grammar deck, I worked from standard beginner and intermediate grammar. For the reading deck, I worked from the standard pronunciation rules for Hangul, batchim, and sound changes. There was no single book behind those decks, which is exactly why every card needed review afterwards.&lt;/p&gt;

&lt;p&gt;Two practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One source at a time.&lt;/strong&gt; Mixing a textbook, a website, and a friend's notes makes it harder to tell where a card came from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If there is no source, say so.&lt;/strong&gt; The AI can still draft the cards, but you are now relying on its general knowledge. Mark that batch for review before you trust it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: Ask for a sample, not the whole deck
&lt;/h2&gt;

&lt;p&gt;This is the step most people skip. Do not ask for 500 cards on the first try. Ask for 20 or 30, look at them, and fix the style while it is cheap.&lt;/p&gt;

&lt;p&gt;A prompt you can copy:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I am learning Korean. Make 25 beginner grammar cards.&lt;/p&gt;

&lt;p&gt;Use five columns: Pattern, Meaning, How it is formed, Two examples, Russian translation.&lt;/p&gt;

&lt;p&gt;One grammar pattern per card. Keep the explanation short. Use natural Korean examples with a romanization and an English translation. Do not mix two patterns into one card.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read the sample and ask yourself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is each card about one thing, or is it a mini lesson?&lt;/li&gt;
&lt;li&gt;Do the example sentences sound like something a person would say?&lt;/li&gt;
&lt;li&gt;Is the translation accurate?&lt;/li&gt;
&lt;li&gt;Is the romanization consistent?&lt;/li&gt;
&lt;li&gt;Are two cards testing the same pattern?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then tell the AI what to change. "Shorter explanations, one example instead of three, and put the Russian at the bottom" is enough. A good sample saves you from redoing 300 cards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Generate in batches, by topic
&lt;/h2&gt;

&lt;p&gt;Once the sample looks right, ask for one topic at a time. For Korean grammar, that meant separate batches for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;beginner patterns: particles, numbers, negation, basic tenses,&lt;/li&gt;
&lt;li&gt;intermediate patterns: connectors, cause and contrast, honorifics,&lt;/li&gt;
&lt;li&gt;reading: Hangul letters, batchim, and sound changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ask for the same columns every time. A spreadsheet is the easiest middle step: paste the table into Google Sheets, Excel, or LibreOffice, check it, and save it as a CSV file. CSV is just a plain spreadsheet file that Anki can read.&lt;/p&gt;

&lt;p&gt;Why batches?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If one batch is bad, you redo one topic, not the whole deck.&lt;/li&gt;
&lt;li&gt;You can review 100 cards in one sitting.&lt;/li&gt;
&lt;li&gt;You notice gaps while the topic is still fresh in your mind.&lt;/li&gt;
&lt;li&gt;You can study beginner cards while the intermediate batch is still being checked.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: Import the cards into Anki
&lt;/h2&gt;

&lt;p&gt;In Anki:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;File&lt;/code&gt; → &lt;code&gt;Import&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose the CSV file.&lt;/li&gt;
&lt;li&gt;Pick the deck (or create a new one).&lt;/li&gt;
&lt;li&gt;Match the columns to the card fields: Pattern, Meaning, Formation, Examples.&lt;/li&gt;
&lt;li&gt;Import.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first import takes a couple of minutes. After that it is routine. If you want separate decks per level, import each batch into its own deck — for example, &lt;code&gt;Korean Grammar::Beginner&lt;/code&gt; and &lt;code&gt;Korean Grammar::Intermediate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can also make the cards look better later: bold the pattern, add color, or make the examples stand out. That is cosmetic. Get the content right first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Updating a deck you already have
&lt;/h2&gt;

&lt;p&gt;This is where AI saves the most time, and where most people accidentally create duplicates.&lt;/p&gt;

&lt;p&gt;The trick is to show the AI your current deck before asking for more. Export your deck from Anki as a CSV or a plain text file, upload it, and say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Here is my current vocabulary deck. I want to add the top 50 Korean verbs. Find the ones I already have and give me only the missing cards, in the same columns. Do not repeat anything that is already in the deck.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I did exactly this with Korean vocabulary. Of the top 50 verbs, 46 were already in my deck. Only four were missing: 걷다, 뛰다, 잃다, and 헤어지다. With the top 50 nouns, 42 already existed and only 8 were new.&lt;/p&gt;

&lt;p&gt;Without checking first, I would have added 88 duplicates. A deck full of duplicates is not just annoying — it wastes review time every single day.&lt;/p&gt;

&lt;p&gt;"Look at what I have, then give me only what is missing" is the single most useful sentence in this whole workflow.&lt;/p&gt;

&lt;p&gt;You can use the same trick to improve existing cards:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;These are my current cards. Find the ones with unnatural example sentences, inconsistent romanization, or two ideas on one card. Give me a corrected version and explain what you changed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 6: Review like an editor, not like a customer
&lt;/h2&gt;

&lt;p&gt;The AI will produce cards that sound right. Some of them will be wrong. That is normal, and it is your job to catch it.&lt;/p&gt;

&lt;p&gt;What I check in a language deck:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Meaning and translation.&lt;/strong&gt; Does the English or Russian actually match the Korean? A wrong translation is worse than no card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Naturalness.&lt;/strong&gt; Is this how people speak, or is it textbook-only Korean that nobody uses?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Romanization.&lt;/strong&gt; Is it consistent? In my Korean deck, a check found four romanization mistakes, including &lt;code&gt;오자마자&lt;/code&gt; written as &lt;em&gt;ojamaso&lt;/em&gt; instead of &lt;em&gt;ojamaja&lt;/em&gt;, and &lt;code&gt;닦아요&lt;/code&gt; written as &lt;em&gt;dakayo&lt;/em&gt; instead of &lt;em&gt;dagayo&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pronunciation rules.&lt;/strong&gt; For reading cards, does the pronounced form match the standard rules, or did the AI simplify it?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One idea per card.&lt;/strong&gt; If a card asks for a pattern, its meaning, three examples, and an exception, it is not a flashcard anymore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own errors.&lt;/strong&gt; If you are not sure about a card, ask a native speaker, check a dictionary, or remove the card until you are sure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A useful extra prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Check these cards against the source. List any card that is not supported, any card that repeats another card, and any translation or romanization you are not confident about. Do not rewrite them yet — just flag them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then fix the flagged ones yourself. An AI reviewing its own work is not perfect, but it catches more than no review at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the finished Anki language decks looked like
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Deck&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;th&gt;Cards&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Grammar::01 Beginner&lt;/td&gt;
&lt;td&gt;82&lt;/td&gt;
&lt;td&gt;134&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grammar::02 Intermediate&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;td&gt;134&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reading Rules&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;224&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;327&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On top of that, the vocabulary decks got two focused sets: 50 top verbs and 50 top nouns. Most of those were already in the collection, so the AI's job was to find the gaps, not to rewrite what existed.&lt;/p&gt;

&lt;p&gt;The grammar cards came in two forms: a meaning card (see the pattern, explain what it means) and a drill card (see an English task, produce the Korean pattern). The reading cards asked "how do you read this?" and showed the pronunciation and the rule on the back.&lt;/p&gt;

&lt;p&gt;That variety came from a decision made at the start, not from asking the AI to "make it good".&lt;/p&gt;

&lt;h2&gt;
  
  
  How much time does it actually save?
&lt;/h2&gt;

&lt;p&gt;These are rough numbers from my own runs, not a scientific study. Card writing speed varies a lot.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;By hand&lt;/th&gt;
&lt;th&gt;With AI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Korean grammar, 268 cards&lt;/td&gt;
&lt;td&gt;6–9 hours&lt;/td&gt;
&lt;td&gt;2–3 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Korean reading, 59 cards&lt;/td&gt;
&lt;td&gt;1.5–2 hours&lt;/td&gt;
&lt;td&gt;30–60 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding 4 missing verbs to a 50-word set&lt;/td&gt;
&lt;td&gt;20–30 minutes&lt;/td&gt;
&lt;td&gt;5–10 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adding 8 missing nouns to a 50-word set&lt;/td&gt;
&lt;td&gt;30–45 minutes&lt;/td&gt;
&lt;td&gt;10 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is consistent: &lt;strong&gt;the AI saves the first-draft time, and you still pay the review time.&lt;/strong&gt; A card by hand takes one to two minutes. Drafting it with AI takes seconds. Checking it takes maybe ten to twenty seconds. That is where the roughly 3–4× improvement comes from.&lt;/p&gt;

&lt;p&gt;It is not 100×, and anyone who tells you it is has not checked their cards. The first draft is no longer the bottleneck. Accuracy is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copy-paste prompts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build a grammar deck from a textbook&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I am learning Korean. I have uploaded [chapter or reference]. Build a study deck one topic at a time. Use the columns Pattern, Meaning, Formation, Two examples, Russian translation. One pattern per card. Use natural examples. Do not invent grammar that is not in the source.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Build a reading deck&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Make cards for reading Hangul. Use the columns Prompt, Answer, Examples, Russian. The front shows a letter, syllable, or word. The back shows the pronunciation and the rule that explains it. Include batchim and sound changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Update an existing deck&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Here is my current deck. Here is the new word list or chapter. Find what I am missing and give me only the new cards, in the same columns. Do not repeat existing cards.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Audit a batch&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Check these cards against the source. Flag anything unsupported, duplicated, unnatural, or uncertain. Do not rewrite them yet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Fix the style&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;These explanations are too long. Rewrite them as one short sentence each. Keep the same pattern and the same examples.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Vibe coding taught us that you can build software without typing every line yourself. Vibe-decking is the same deal for language learning: you can build a serious deck without writing every card yourself.&lt;/p&gt;

&lt;p&gt;But the lesson from vibe coding applies here too. You can generate without coding, and you can generate without writing cards — you just cannot skip reading the result. The AI is a fast, tireless assistant who has read a lot about your language and might still get a translation or a pronunciation wrong. Your job is to be the editor.&lt;/p&gt;

&lt;p&gt;Used that way, it is not a gimmick. It is how I went from "I should make a proper Korean deck" to 327 grammar and reading cards — plus two cleaned-up vocabulary sets — without spending a week at the keyboard.&lt;/p&gt;

</description>
      <category>anki</category>
      <category>languagelearning</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why I'm building GwinG, a language exchange app for awkward hellos</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Tue, 20 Oct 2026 10:34:03 +0000</pubDate>
      <link>https://dev.to/frankmilvus/why-im-building-gwing-a-language-exchange-app-for-awkward-hellos-38hg</link>
      <guid>https://dev.to/frankmilvus/why-im-building-gwing-a-language-exchange-app-for-awkward-hellos-38hg</guid>
      <description>&lt;h2&gt;
  
  
  I'm not a language influencer
&lt;/h2&gt;

&lt;p&gt;I'm FrankMilvus, and I build &lt;strong&gt;GwinG&lt;/strong&gt;, a language exchange app. No, I'm not fluent in seven languages, and I don't have a 30-day method to sell you. I'm a developer who wanted to talk to people and got annoyed at how much work the apps made it feel like.&lt;/p&gt;

&lt;p&gt;GwinG is the thing I wish I had about five years ago, when I installed a couple of the big language-exchange apps, found people to talk to, and then realised the apps were much better at matching than at the conversation that was supposed to come after.&lt;/p&gt;

&lt;p&gt;The idea is roughly five years old. The current codebase is not. I started the version you can install today in July, and the repo is already at almost 900 commits. That number sounds impressive until you notice how many of them say "fix" or "release" and nothing else.&lt;/p&gt;

&lt;p&gt;Up front: GwinG is not trying to be the biggest messenger in the world. It's a small product for a specific moment. The moment you want to say something to someone who speaks another language, and you don't want to spend ten minutes translating it in your head first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part most language exchange apps skip
&lt;/h2&gt;

&lt;p&gt;Five years ago I wanted to find someone to speak with. Not a course. Not a flashcard deck. A person.&lt;/p&gt;

&lt;p&gt;I tried a couple of the usual apps. They did the matching part fine: here's a profile, here's a language, here's a button. Then came the actual conversation, and that part was on me.&lt;/p&gt;

&lt;p&gt;Finding a person is a directory problem. Having a conversation is a product problem. A lot of apps stop after the directory.&lt;/p&gt;

&lt;p&gt;That's the gap I kept running into. The first message is easy enough. It's the fifth one that gets awkward. You run out of things you can say with the vocabulary you have, the other person switches to English to be helpful, and suddenly the language exchange is over and you're just two people chatting in the same language again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wanted instead
&lt;/h2&gt;

&lt;p&gt;I wanted the app to carry some of that weight.&lt;/p&gt;

&lt;p&gt;If I have to copy a message, paste it into a translator, copy the result back, and hope the tone survived, I'm not having a conversation anymore. I'm doing data entry with extra steps. So GwinG puts translation inside the chat. You write in your language, the other person reads in theirs, and the original is always one tap away.&lt;/p&gt;

&lt;p&gt;Voice messages work the same way. Transcription turns them into text you can read, search and translate. That matters more than it sounds. A voice note in a language you're still learning is basically a locked box. Text makes it something you can actually work with.&lt;/p&gt;

&lt;p&gt;The point isn't to hide the other language. It's to give you a ladder. You read the translation, then you look at the original, and slowly you need the translation less. That's the whole trick. It's not magic, and it doesn't replace the awkward part. It just gives you something to hold onto while you get through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GwinG actually is
&lt;/h2&gt;

&lt;p&gt;The short version: a messenger. Chats and groups, voice and video calls, photos, voice notes, video, files, replies, reactions, pins, search, friends, presence. The usual stuff, plus the translation layer that makes the exchange part possible.&lt;/p&gt;

&lt;p&gt;Under the hood it's &lt;a href="https://flutter.dev" rel="noopener noreferrer"&gt;Flutter&lt;/a&gt; on the client and &lt;a href="https://fastapi.tiangolo.com" rel="noopener noreferrer"&gt;FastAPI&lt;/a&gt; with PostgreSQL on the server. WebSockets for realtime, WebRTC for calls, object storage for media, and small services for speech and translation. Android, iOS, Windows and Linux. The code is mostly mine. I'm the person who gets to fix the weird desktop bug at 1am, which is a very effective way to learn desktop packaging.&lt;/p&gt;

&lt;p&gt;Not because I'm some 10x developer. Mostly because the app is small enough to hold in my head, and I'm stubborn.&lt;/p&gt;

&lt;h2&gt;
  
  
  The team is basically a group chat
&lt;/h2&gt;

&lt;p&gt;There are a few of us. We're spread out. Spain, South Korea, a few other places. So the app gets built across time zones whether we plan for it or not.&lt;/p&gt;

&lt;p&gt;Between us we speak English, Spanish and Russian, and we're currently poking at Korean and German. Which means we're not guessing what it feels like to be the person who understands half the conversation and smiles anyway. We've all been that person.&lt;/p&gt;

&lt;p&gt;That's also why a lot of the product decisions are boring on purpose. If a feature only works when everyone is already fluent, it's not a language-exchange feature. It's a feature for people who don't need the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The name is not the product
&lt;/h2&gt;

&lt;p&gt;People ask what GwinG means. Honest answer: it's a project name.&lt;/p&gt;

&lt;p&gt;We don't care about it the way you care about a child's name. If the app is good, the name becomes good. If the app is bad, no name saves it. We already changed it once, from tonGues, and the thing that mattered was that nothing underneath changed. Same accounts, same conversations, same keys. The name is a label on the box. The box is the product. If you want the whole rename story, &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-18-why-we-renamed-tongues-to-gwing.html" rel="noopener noreferrer"&gt;it's here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What I like about GwinG is that it doesn't pretend to mean anything in particular. It's five letters with a capital at each end. It's ours. That's enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Getting better" is the whole roadmap
&lt;/h2&gt;

&lt;p&gt;The line I keep coming back to is this: we're building a platform that lets people get better.&lt;/p&gt;

&lt;p&gt;Not just better at grammar. Better at the messy part. Saying something before you're ready. Asking someone to repeat themselves without dying inside. Laughing at the fact that you just used the wrong word for "chicken" in front of a stranger. Coming back the next day anyway.&lt;/p&gt;

&lt;p&gt;Fluency is a long game. Confidence is a shorter one. We can help with the shorter one.&lt;/p&gt;

&lt;p&gt;So no, we're not promising you'll be fluent in 30 days. Nobody can promise that. We're trying to make the first message less scary and the tenth one easier. That's the whole pitch. It's not a growth hack, and it doesn't fit in a billboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're working on now
&lt;/h2&gt;

&lt;p&gt;We're still in the unglamorous phase.&lt;/p&gt;

&lt;p&gt;Calls that connect on the first try. Messages that don't get lost when the network blips. Translation that doesn't sound like a robot reading a legal document. Desktop builds that behave like desktop apps instead of a phone app in a big window. The list is long, and almost none of it makes a good demo.&lt;/p&gt;

&lt;p&gt;That's also why I write everything up. The good changes, the ugly fixes, the ones that looked simple and turned into a three-day detour. You can find the longer version of the project in &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-11-what-is-tongues-language-exchange-messenger.html" rel="noopener noreferrer"&gt;the overview post&lt;/a&gt;, or just follow the articles as they come out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to find us
&lt;/h2&gt;

&lt;p&gt;The code is private for now, but the decisions aren't. If you want to follow along, argue with a design choice, or tell me the app is missing the one feature you actually need, here's where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus" rel="noopener noreferrer"&gt;https://github.com/FrankMilvus&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dev.to:&lt;/strong&gt; &lt;a href="https://dev.to/frankmilvus"&gt;https://dev.to/frankmilvus&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram:&lt;/strong&gt; &lt;a href="https://t.me/gwingorg" rel="noopener noreferrer"&gt;https://t.me/gwingorg&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Articles:&lt;/strong&gt; &lt;a href="https://frankmilvus.github.io/tongues-articles/" rel="noopener noreferrer"&gt;https://frankmilvus.github.io/tongues-articles/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Say hi. I read everything, including the messages that start with "this is probably a stupid question." Those are usually the good ones.&lt;/p&gt;

&lt;p&gt;— FrankMilvus&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Get GwinG&lt;/strong&gt; — a language-exchange messenger with live translation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.milvus.tongue" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/tonGue-releases/releases/latest" rel="noopener noreferrer"&gt;.zip or installer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/tonGue-releases/releases/latest" rel="noopener noreferrer"&gt;.deb or portable tarball&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>languageexchange</category>
      <category>flutter</category>
      <category>showdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>A Flutter tab shell that keeps every page alive</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Mon, 19 Oct 2026 11:59:35 +0000</pubDate>
      <link>https://dev.to/frankmilvus/a-flutter-tab-shell-that-keeps-every-page-alive-1omj</link>
      <guid>https://dev.to/frankmilvus/a-flutter-tab-shell-that-keeps-every-page-alive-1omj</guid>
      <description>&lt;h2&gt;
  
  
  The spinner-on-every-tab problem
&lt;/h2&gt;

&lt;p&gt;A bottom navigation bar looks like a solved problem. Four destinations, a selected index, a page for each one. The naive implementation is a &lt;code&gt;pushReplacementNamed&lt;/code&gt; on every tap: each destination is its own route, and switching tabs swaps the route.&lt;/p&gt;

&lt;p&gt;That works until the app has state worth keeping. In GwinG, each main tab owns real, expensive state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search runs a debounced user query, holds a map preview and polls presence for the visible results.&lt;/li&gt;
&lt;li&gt;Friends owns the chat list, chat tabs, scroll positions, an avatar cache and a WebSocket subscription.&lt;/li&gt;
&lt;li&gt;More is a menu whose keyboard cursor should survive a detour to another tab.&lt;/li&gt;
&lt;li&gt;Profile renders the signed-in user's data and media.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With route replacement, every tap re-ran &lt;code&gt;initState&lt;/code&gt;: another friends request, another version check, another avatar prewarm, another presence poll, and a scroll position reset to the top. The app was not slow because the network was slow. It was slow because it kept throwing work away.&lt;/p&gt;

&lt;p&gt;The fix was to host all four tabs in &lt;strong&gt;one route&lt;/strong&gt; and switch the visible child instead of replacing the route.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shell in one widget
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HomeShell&lt;/code&gt; is a &lt;code&gt;StatefulWidget&lt;/code&gt; that takes an initial index, builds the four pages once, and keeps them in an &lt;code&gt;IndexedStack&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Scaffold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;body:&lt;/span&gt; &lt;span class="n"&gt;FadeTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;opacity:&lt;/span&gt; &lt;span class="n"&gt;_tabFade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;IndexedStack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;index:&lt;/span&gt; &lt;span class="n"&gt;_index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;children:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;_pages&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="n"&gt;HeroMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nl"&gt;enabled:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;_index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;_visited&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_pages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;SizedBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shrink&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
          &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;bottomNavigationBar:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NavbarWidget&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three deliberate choices are packed into that tree.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Lazy visits, eager Friends
&lt;/h3&gt;

&lt;p&gt;An &lt;code&gt;IndexedStack&lt;/code&gt; builds every child it is given, even the ones that are not visible. Building all four tabs at startup would start four network paths before the user has asked for any of them. The shell therefore tracks a &lt;code&gt;_visited&lt;/code&gt; set and substitutes an empty placeholder for tabs the user has never opened. A page's &lt;code&gt;initState&lt;/code&gt; — and its first request — runs on the first visit, not at app launch.&lt;/p&gt;

&lt;p&gt;The exception is Friends. Friends is the home tab in the product's mental model, so it is added to &lt;code&gt;_visited&lt;/code&gt; at startup. By the time the user taps it, the list is already loaded or already loading. The tradeoff is one request at launch in exchange for never showing a spinner on a tab people open constantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. One source of truth for the selected tab
&lt;/h3&gt;

&lt;p&gt;The navbar does not navigate. It publishes an index to &lt;code&gt;selectedNavBarNotifier&lt;/code&gt;; the shell listens and moves the stack. The same notifier is written by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a navbar tap,&lt;/li&gt;
&lt;li&gt;a horizontal page swipe,&lt;/li&gt;
&lt;li&gt;the keyboard tab shortcuts,&lt;/li&gt;
&lt;li&gt;a deep link or notification that asks for a specific tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because every entry point writes the same value, the highlight, the visible page and the persisted "last tab" cannot disagree. A second notifier, &lt;code&gt;selectedPageNotifier&lt;/code&gt;, records the tab for the next cold start. The shell renders; the notifiers decide.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A fade, not a rebuild
&lt;/h3&gt;

&lt;p&gt;Switching tabs is a &lt;code&gt;FadeTransition&lt;/code&gt; driven by a short &lt;code&gt;AnimationController&lt;/code&gt;. It is subtle on purpose: the pages are already alive, so a large transition would draw attention to the fact that nothing was rebuilt. The duration also goes through the app's motion settings, so reduced-motion users get an instant switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "alive" actually buys
&lt;/h2&gt;

&lt;p&gt;Keeping a page mounted is not just about avoiding a spinner. It preserves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scroll position&lt;/strong&gt; in long chat and search lists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-flight work&lt;/strong&gt;: a search request or a friend refresh that started before the switch can finish and update the tab while it is hidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscriptions&lt;/strong&gt;: WebSocket listeners and presence polling keep running, so returning to Friends shows current data instead of a stale snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draft UI state&lt;/strong&gt;: an open search box, an expanded menu or a keyboard cursor is still where it was.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image cache warmth&lt;/strong&gt;: avatars and media already decoded do not flash back in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The price is that hidden pages are still &lt;em&gt;running&lt;/em&gt;. That is the part the shell has to manage carefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hidden pages are still listening
&lt;/h2&gt;

&lt;p&gt;Every page that registers a keyboard handler, a WebSocket listener or a timer is still registered while it is off-screen. Without a gate, pressing a search shortcut on the Profile tab could move a cursor in a Search page nobody can see.&lt;/p&gt;

&lt;p&gt;The codebase uses two gates consistently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In a page's raw key handler:&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ModalRoute&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;isCurrent&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selectedNavBarNotifier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;_myTabIndex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line rejects input while a pushed page or dialog is on top. The second rejects input while another tab is visible. The same predicate is passed to the F1 hotkey-help registry, so the cheat sheet only advertises chords that actually work on the current screen.&lt;/p&gt;

&lt;p&gt;Hero animations need the same treatment. Off-screen tabs stay mounted, so their &lt;code&gt;Hero&lt;/code&gt; widgets are still in the tree. Without &lt;code&gt;HeroMode(enabled: i == _index)&lt;/code&gt;, a profile opened from Search could fly its avatar toward a hidden Friends row that happens to share the same tag. The shell enables heroes only for the visible child.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layering swipe navigation on top
&lt;/h2&gt;

&lt;p&gt;Once the shell exists, adding a horizontal page swipe is straightforward — provided each page decides who owns the gesture.&lt;/p&gt;

&lt;h3&gt;
  
  
  More and Search: a page-level wrapper
&lt;/h3&gt;

&lt;p&gt;These pages have no inner pager, so they put &lt;code&gt;HomeTabSwipe&lt;/code&gt; around the whole body. The wrapper recognises a decisive horizontal drag, applies distance and velocity thresholds, ignores drags near the left edge (the platform back gesture), and calls the shared tab-switch function with &lt;code&gt;-1&lt;/code&gt; or &lt;code&gt;+1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Inner gestures keep priority automatically. On Search, the map preview pans horizontally, and the vertical scroll view owns vertical drags; the page wrapper only sees drags that neither of them claimed. That is why the shell-swipe test starts its fling &lt;em&gt;below&lt;/em&gt; the map instead of on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Friends: hand off at the edges only
&lt;/h3&gt;

&lt;p&gt;Friends cannot use a page-level wrapper for normal swipes, because its body already contains a &lt;code&gt;PageView&lt;/code&gt; for chat tabs. Swiping inside that pager must move between chat tabs, not between shell tabs.&lt;/p&gt;

&lt;p&gt;The solution is a raw pointer listener around the &lt;code&gt;PageView&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It records the pointer position and the current page on pointer-down.&lt;/li&gt;
&lt;li&gt;On pointer-up it measures horizontal travel and rejects the gesture if it is mostly vertical.&lt;/li&gt;
&lt;li&gt;Only when the pager is already on its &lt;strong&gt;first&lt;/strong&gt; tab and the swipe goes backward, or on its &lt;strong&gt;last&lt;/strong&gt; tab and the swipe goes forward, does it hand off to the shell.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the listener observes raw pointer events and never joins the gesture arena, the inner pager keeps its normal drag behaviour. The shell only takes over at the boundary, which is exactly what a user expects: swipe through your chat tabs, and keep swiping to leave the page.&lt;/p&gt;

&lt;p&gt;The shell swipe wrapper is still used on Friends — but only in the loading state, where no &lt;code&gt;PageView&lt;/code&gt; exists yet. That detail is the subject of the companion post, &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-19-swipe-navigation-during-loading.html" rel="noopener noreferrer"&gt;Flutter swipe navigation that keeps working while the app loads&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not every page needs a swipe
&lt;/h3&gt;

&lt;p&gt;The wrapper is opted into per page. Profile, for example, does not install a page-level swipe; its content is a vertically scrolling profile view, and the navbar and keyboard remain the navigation paths. This is a feature of the design, not an omission: gestures should be added where they do not compete with the page's own interactions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pull-to-refresh and maps in the same tree
&lt;/h2&gt;

&lt;p&gt;Search combines three gesture layers that could easily fight each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A horizontal page swipe at the shell level.&lt;/li&gt;
&lt;li&gt;A vertical pull-to-refresh indicator.&lt;/li&gt;
&lt;li&gt;A map that pans in both directions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They coexist because each recogniser is scoped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;RefreshIndicator&lt;/code&gt; wraps a &lt;code&gt;SingleChildScrollView&lt;/code&gt; with &lt;code&gt;AlwaysScrollableScrollPhysics&lt;/code&gt;, so the pull works even when the result list is shorter than the viewport.&lt;/li&gt;
&lt;li&gt;The map owns drags that start inside its bounds.&lt;/li&gt;
&lt;li&gt;The page wrapper only accepts horizontal drags that the scroll view and map did not claim.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a page adds a new horizontal child — a carousel, a swipeable row, a pager — the page-level swipe should be re-checked. The gesture arena will do the right thing most of the time, but "most of the time" is how dead zones and accidental tab switches get shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back behaviour and persistence
&lt;/h2&gt;

&lt;p&gt;The shell changes what "back" means. On a main tab, the system back button minimises the app; on a sub-page (Settings, a chat, About), it pops back to the shell. The root wrapper implements that distinction once, so individual pages do not need their own &lt;code&gt;PopScope&lt;/code&gt; handlers.&lt;/p&gt;

&lt;p&gt;Persistence has a subtle failure mode the shell had to fix. The last visible page index was being written even while the user was on a sub-page. After the process was killed and restored, the app could cold-start on a route that is not one of the four tabs and has no navbar. The restore path now normalises the saved index to a tab route before navigating; sub-page indices map to their parent tab. The rule is simple: &lt;strong&gt;persist a destination, not an arbitrary route&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing a shell
&lt;/h2&gt;

&lt;p&gt;The shell's tests are mostly about the invariants that are easy to break during a refactor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;home_tab_swipe_test.dart&lt;/code&gt; checks the gesture thresholds, direction mapping, the edge guard and the desktop no-op.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;more_shell_swipe_test.dart&lt;/code&gt; and &lt;code&gt;search_shell_swipe_test.dart&lt;/code&gt; assert that a swipe moves the shell notifier and the persisted page index.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;friends_shell_swipe_test.dart&lt;/code&gt; checks all three behaviours: an outward swipe at the first/last chat tab switches shell tabs, an inner swipe stays inside Friends, and a swipe during the loading skeleton switches shell tabs.&lt;/li&gt;
&lt;li&gt;Loading-state tests hold the async source open (a never-completing token read or friends fetch) so the dead state is actually reachable. A test that only pumps the loaded page cannot catch a missing wrapper.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The assertions are on navigation state, not on pixels. That keeps them stable across theme and layout changes while still failing when a gesture is silently dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs worth naming
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Memory versus refetching.&lt;/strong&gt; Every visited tab stays in memory. For four tabs with image-heavy lists this is a real cost, mitigated by lazy first visits and by disposing per-tab controllers only when the shell itself goes away. The alternative — rebuilding and refetching on every tap — is a worse user experience and more network traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eager Friends.&lt;/strong&gt; Pre-building one tab at startup is a deliberate asymmetry. It spends a little launch work to remove a spinner from the tab users open most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fade versus slide.&lt;/strong&gt; A slide transition suggests spatial movement, which promises that a swipe will move the page with your finger. The shell does not implement that kind of pager; it switches instantly (with a short fade) and treats swipes as commands. Promising less is more honest than a half-implemented parallax.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One route, many states.&lt;/strong&gt; Because the tabs are not routes, deep links need a mapping layer that both navigates to the shell and selects the right tab. That is a small amount of code, paid once, instead of duplicating state ownership across routes.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why not use a &lt;code&gt;PageView&lt;/code&gt; for the whole shell?&lt;/strong&gt;&lt;br&gt;
A shell-level pager would own horizontal drags that several pages already use — chat tabs, maps, carousels and text selection. It would also keep every page's width in the layout and make state preservation depend on the pager's keep-alive behaviour. The &lt;code&gt;IndexedStack&lt;/code&gt; plus per-page gesture wrappers is simpler to reason about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do hidden tabs keep making requests?&lt;/strong&gt;&lt;br&gt;
They can, and that is intentional for subscriptions and in-flight work. Each page still gates user input on visibility, and long-lived listeners are removed when the page is disposed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does Friends appear before I tap it?&lt;/strong&gt;&lt;br&gt;
It is added to the shell's visited set at startup so it can load in the background. The other tabs stay unbuilt until first use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does swiping work on desktop?&lt;/strong&gt;&lt;br&gt;
No. The gesture wrapper is mobile-only; desktop uses the visible navigation bar and keyboard shortcuts. A horizontal mouse drag is usually selection, not navigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens in right-to-left languages?&lt;/strong&gt;&lt;br&gt;
The wrapper maps the swipe direction to the layout's text direction, and the destination is clamped at the first and last tab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How are dialogs and pushed pages handled?&lt;/strong&gt;&lt;br&gt;
Both gates — "this route is current" and "this tab is visible" — fail while a pushed page or dialog is on top, so shell shortcuts and gestures stay out of the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;If you are building a shell like this, start with the state model: one notifier for the selected tab, one persisted index, and a lazy &lt;code&gt;IndexedStack&lt;/code&gt;. Add gestures one page at a time, checking what each page already owns. Then write tests that assert the navigation state while the app is loading, not only after it has finished. The loading state is where navigation bugs hide.&lt;/p&gt;

&lt;p&gt;For the specific loading-state fix that started this pair of posts, see &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-19-swipe-navigation-during-loading.html" rel="noopener noreferrer"&gt;Flutter swipe navigation that keeps working while the app loads&lt;/a&gt;. For what the app itself is, start with the &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-11-what-is-tongues-language-exchange-messenger.html" rel="noopener noreferrer"&gt;GwinG overview&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Get GwinG&lt;/strong&gt; — a language-exchange messenger with live translation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.milvus.tongue" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/tonGue-releases/releases/latest" rel="noopener noreferrer"&gt;.zip or installer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/tonGue-releases/releases/latest" rel="noopener noreferrer"&gt;.deb or portable tarball&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>showdev</category>
      <category>flutter</category>
      <category>mobile</category>
      <category>ux</category>
    </item>
    <item>
      <title>Flutter swipe navigation that keeps working while the app loads</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Mon, 19 Oct 2026 11:58:57 +0000</pubDate>
      <link>https://dev.to/frankmilvus/flutter-swipe-navigation-that-keeps-working-while-the-app-loads-1eig</link>
      <guid>https://dev.to/frankmilvus/flutter-swipe-navigation-that-keeps-working-while-the-app-loads-1eig</guid>
      <description>&lt;h2&gt;
  
  
  The swipe that only worked after the list appeared
&lt;/h2&gt;

&lt;p&gt;GwinG is a language-exchange messenger with four main tabs: More, Search, Friends and Profile. On a phone, a horizontal swipe on a page body moves to the neighbouring tab, just like tapping the bottom navigation bar. It is a small convenience, but it is the kind of gesture people use without thinking.&lt;/p&gt;

&lt;p&gt;A reader reported that it did not always work. Opening the Search tab on a slow connection and swiping left did nothing. As soon as the user list appeared, the same swipe worked. The gesture was not broken. It was simply not there during the part of the session where the app was waiting for the network.&lt;/p&gt;

&lt;p&gt;That report was a useful reminder: &lt;strong&gt;a loading state is still a screen&lt;/strong&gt;. If a screen can be looked at, it can be swiped, tapped, or dismissed. Treating loading as a placeholder that does not need navigation logic means every slow connection briefly traps the user on one tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tabs, three loading states
&lt;/h2&gt;

&lt;p&gt;The first step was to stop thinking about "loading" as one state. Search alone has two of them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The sign-in spinner.&lt;/strong&gt; Before the app has finished checking its saved session, Search renders a full-page &lt;code&gt;CircularProgressIndicator&lt;/code&gt; instead of the map, search field and results. This is a different widget tree from the loaded page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The results skeleton.&lt;/strong&gt; Once the session is ready, the normal page renders, but the results area shows shimmering skeleton rows while the first request is in flight.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Friends has a third:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The chats skeleton.&lt;/strong&gt; The chat list shows skeleton rows until the first friends response arrives. Its shell hand-off lives inside a &lt;code&gt;PageView&lt;/code&gt; that only exists once the list is loaded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only one of those three was originally wrapped in the swipe handler. The results skeleton was already inside the normal Search page body, so it inherited the gesture. The sign-in spinner was an early return with no wrapper. The Friends skeleton replaced the entire page that owned the &lt;code&gt;PageView&lt;/code&gt;, so its swipe logic was gone too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrapper: one page, one horizontal gesture
&lt;/h2&gt;

&lt;p&gt;The shell uses a small widget called &lt;code&gt;HomeTabSwipe&lt;/code&gt;. It is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;GestureDetector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;behavior:&lt;/span&gt; &lt;span class="n"&gt;HitTestBehavior&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opaque&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;onHorizontalDragStart:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_dx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;_ignored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;globalPosition&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dx&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;_edgeGuard&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nl"&gt;onHorizontalDragUpdate:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;_ignored&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;_dx&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delta&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nl"&gt;onHorizontalDragEnd:&lt;/span&gt; &lt;span class="n"&gt;_onDragEnd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;onHorizontalDragCancel:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_dx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opaque hit testing.&lt;/strong&gt; A swipe that starts on the gap between rows still counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A distance and a velocity threshold.&lt;/strong&gt; A slow drag needs 48 logical pixels; a quick flick needs 320 pixels per second. Direction comes from the flick when there is one, otherwise from the finger's travel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An edge guard.&lt;/strong&gt; Drags starting within 24 pixels of the left edge are left to the platform's back-swipe gesture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RTL awareness.&lt;/strong&gt; "Next tab" is left in a left-to-right layout and right in a right-to-left one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile only.&lt;/strong&gt; On desktop, tabs are switched with the keyboard or a click; inventing a drag gesture for a mouse would only fight text selection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inner horizontal gestures keep priority. A map pans, a horizontally scrolling list scrolls, a text field selects text, and an inner &lt;code&gt;PageView&lt;/code&gt; pages. The page-level wrapper only sees the drags that no child claimed. That is exactly the behaviour a shell wants.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: make Search swipeable from the first frame
&lt;/h2&gt;

&lt;p&gt;The sign-in branch returned its own &lt;code&gt;Scaffold&lt;/code&gt;, so the fix was to wrap its body in the same gesture widget the loaded page uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Scaffold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;appBar:&lt;/span&gt; &lt;span class="n"&gt;AppBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;toolbarHeight:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nl"&gt;body:&lt;/span&gt; &lt;span class="n"&gt;HomeTabSwipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;onSwipe:&lt;/span&gt; &lt;span class="n"&gt;_onSwipeToShell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Center&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;CircularProgressIndicator&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_onSwipeToShell&lt;/code&gt; is the same handler as before: clamp the destination to the four tabs, give a small haptic, and call the shared &lt;code&gt;selectNavTab&lt;/code&gt; function so a swipe and a navbar tap can never drift apart.&lt;/p&gt;

&lt;p&gt;The important part is not the extra widget. It is the rule behind it: &lt;strong&gt;the swipe wrapper belongs at the page boundary, not around the success UI&lt;/strong&gt;. If the page can render a full-screen waiting state, that state goes inside the wrapper too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: give the Friends skeleton the shell hand-off
&lt;/h2&gt;

&lt;p&gt;Friends is more interesting because its swipes normally do two different jobs. Inside the loaded page, a &lt;code&gt;PageView&lt;/code&gt; moves between chat tabs (All, Encrypted, Friends, and any custom tabs). Only when you reach the first or last chat tab and keep swiping does the shell take over and switch to Search or Profile.&lt;/p&gt;

&lt;p&gt;That hand-off is implemented with a raw pointer listener around the &lt;code&gt;PageView&lt;/code&gt;. It watches pointer-down and pointer-up, measures horizontal travel, rejects vertical drags, and hands off only at an outer edge. The listener is outside the gesture arena on purpose: the inner pager still owns normal tab swipes.&lt;/p&gt;

&lt;p&gt;While the chats are loading, there are no chat tabs and no &lt;code&gt;PageView&lt;/code&gt;. There is nothing to page through, so a horizontal swipe can go straight to the neighbouring shell tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;_loading&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;HomeTabSwipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;onSwipe:&lt;/span&gt; &lt;span class="n"&gt;_handOffEdgeSwipe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;_FriendsSkeleton&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_loadFailed&lt;/span&gt;
        &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;AppErrorState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MotionEntrance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* the loaded page */&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reuses the same edge-hand-off function as the loaded page, including its clamping and haptic feedback. No new navigation path, no second definition of "which tab is next".&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing gestures across async boundaries
&lt;/h2&gt;

&lt;p&gt;The regression tests are the part worth copying. A widget test that simply pumps a loaded page cannot catch this class of bug, because the dead state only exists while an async operation is pending.&lt;/p&gt;

&lt;p&gt;For Search, the test holds the secure-storage read open. The method-channel mock returns a future that never completes, so the page stays on the sign-in spinner. The test asserts that the spinner is on screen, flings from the middle of the page, and checks the shell's tab notifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;tokenRead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Completer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// secure-storage mock returns tokenRead.future for every read&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pumpWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* Search inside MaterialApp */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;find&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;byType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CircularProgressIndicator&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;findsOneWidget&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flingFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Offset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Offset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selectedNavBarNotifier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Friends&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Friends, the test injects a &lt;code&gt;FriendsService&lt;/code&gt; whose &lt;code&gt;fetchFriends()&lt;/code&gt; never settles. It asserts that skeleton rows are visible, flings the &lt;code&gt;HomeTabSwipe&lt;/code&gt;, and checks that the shell moved to Search.&lt;/p&gt;

&lt;p&gt;Two practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do not use &lt;code&gt;pumpAndSettle&lt;/code&gt; while a shimmer is animating.&lt;/strong&gt; A repeating animation never settles. Explicit &lt;code&gt;pump&lt;/code&gt; calls with a duration are deterministic and fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make sure the test fails without the fix.&lt;/strong&gt; Asserting "some widget exists" is not enough; assert the navigation result. In both cases the missing wrapper leaves the tab notifier unchanged, so the test fails for the right reason.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also verified the results-skeleton state separately, including drags that start directly on a skeleton row. It already worked, which narrowed the fix to the two dead states instead of adding wrappers everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes for users
&lt;/h2&gt;

&lt;p&gt;Nothing about how the loaded pages look. The only visible difference is that a swipe now works while the app is still checking the session, the friends list, or the first page of search results. On a good connection the change is invisible; on a bad one it removes a two-to-ten-second dead zone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons that generalise
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit every early return.&lt;/strong&gt; A &lt;code&gt;return Scaffold(...)&lt;/code&gt; above the main layout is a separate screen, and it needs the same navigation affordances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrap at the page boundary.&lt;/strong&gt; Put page-level gestures around the whole body, not around the widget that renders data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let inner gestures win.&lt;/strong&gt; Maps, pagers, and text selection should keep their drags; the shell only handles what is left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the waiting state.&lt;/strong&gt; Hold the async source open in a widget test. If the test cannot reach the state, it cannot protect it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse the navigation function.&lt;/strong&gt; Swipe, tap and keyboard all call one &lt;code&gt;selectNavTab&lt;/code&gt;; a loading-state wrapper should use the same callback, not reimplement the rules.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;&lt;strong&gt;Does wrapping a spinner in a gesture detector cost anything?&lt;/strong&gt;&lt;br&gt;
No. It is one gesture recognizer on an otherwise static subtree. It does not rebuild during loading and does not delay the request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not make the whole shell a &lt;code&gt;PageView&lt;/code&gt; and get swipes for free?&lt;/strong&gt;&lt;br&gt;
Because several pages already own horizontal drags, and a shell-level pager would constantly compete with them. The architecture behind this choice is the subject of the companion post, &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-19-flutter-tab-shell-keep-pages-alive.html" rel="noopener noreferrer"&gt;A Flutter tab shell that keeps every page alive&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work in right-to-left languages?&lt;/strong&gt;&lt;br&gt;
Yes. The wrapper maps "next" to the left in LTR and to the right in RTL, and the destination is still clamped at the first and last tab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why only mobile?&lt;/strong&gt;&lt;br&gt;
The gesture is a touch affordance. Desktop users have a visible navigation bar, keyboard shortcuts and a mouse; a horizontal mouse drag is usually selection, not navigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about the platform back gesture on iOS?&lt;/strong&gt;&lt;br&gt;
Drags that start within 24 logical pixels of the left edge are ignored, so the system back swipe keeps working.&lt;/p&gt;

&lt;p&gt;If you want the wider context, the &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-11-what-is-tongues-language-exchange-messenger.html" rel="noopener noreferrer"&gt;GwinG overview&lt;/a&gt; explains what the app is, and the shell post covers how the four tabs stay alive without reloading.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Get GwinG&lt;/strong&gt; — a language-exchange messenger with live translation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; &lt;a href="https://play.google.com/store/apps/details?id=com.milvus.tongue" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/tonGue-releases/releases/latest" rel="noopener noreferrer"&gt;.zip or installer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux:&lt;/strong&gt; &lt;a href="https://github.com/FrankMilvus/tonGue-releases/releases/latest" rel="noopener noreferrer"&gt;.deb or portable tarball&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>showdev</category>
      <category>flutter</category>
      <category>ux</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
