<?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>Video thumbnails without downloading the video</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Sun, 18 Oct 2026 10:54:59 +0000</pubDate>
      <link>https://dev.to/frankmilvus/video-thumbnails-without-downloading-the-video-47f3</link>
      <guid>https://dev.to/frankmilvus/video-thumbnails-without-downloading-the-video-47f3</guid>
      <description>&lt;h2&gt;
  
  
  A video tile that only knew how to say "video"
&lt;/h2&gt;

&lt;p&gt;Open the &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-shared-media-gallery-in-flutter.html" rel="noopener noreferrer"&gt;shared-media view&lt;/a&gt;&lt;br&gt;
in GwinG and you get three tabs: &lt;strong&gt;Media&lt;/strong&gt;, &lt;strong&gt;Files&lt;/strong&gt;, &lt;strong&gt;Links&lt;/strong&gt;. Images have&lt;br&gt;
always had thumbnails there. Videos had a play triangle on&lt;br&gt;
a flat background and nothing else — the same tile for a five-second hello and a&lt;br&gt;
ten-minute walk-through. The only way to tell two clips apart was to open one and&lt;br&gt;
wait for it to start downloading.&lt;/p&gt;

&lt;p&gt;That is the small annoyance we fixed this week. Every video sent from now on&lt;br&gt;
carries a &lt;strong&gt;poster&lt;/strong&gt;: a single JPEG of its first frame, extracted on the sender's&lt;br&gt;
device and stored next to the message. The media grid shows that frame under a&lt;br&gt;
small play badge, so you can recognise a clip before a single byte of video&lt;br&gt;
crosses the network.&lt;/p&gt;

&lt;p&gt;The rest of this post is about the choice behind it: where the frame comes from,&lt;br&gt;
why we did not ask a server to make it, and the one desktop platform that needed&lt;br&gt;
a different recipe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to get a still frame
&lt;/h2&gt;

&lt;p&gt;There are exactly two honest options for a video poster, and they differ in &lt;em&gt;who&lt;/em&gt;&lt;br&gt;
does the work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extract on the server
&lt;/h3&gt;

&lt;p&gt;The classic approach. When a video finishes uploading, a worker runs a video tool&lt;br&gt;
over it, grabs frame zero, stores the result, and attaches the URL. It is robust.&lt;br&gt;
It works for every video in the database, including the ones sent years before&lt;br&gt;
the feature existed, because a backfill job can walk the library and generate the&lt;br&gt;
missing posters.&lt;/p&gt;

&lt;p&gt;The bill is real, though. It puts a video decoder in the API image, spends server&lt;br&gt;
CPU on every upload, and requires a schema and API change anyway. For a small&lt;br&gt;
team, "add a media pipeline" is a project, not a line item.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extract on the sender's device
&lt;/h3&gt;

&lt;p&gt;The alternative: the phone that is about to send the clip already has the file&lt;br&gt;
and, in most cases, already has a decoded frame. Grab that frame before the&lt;br&gt;
upload, encode it as a JPEG, and upload it as an ordinary image. The message then&lt;br&gt;
points at both files — video and poster.&lt;/p&gt;

&lt;p&gt;This is the familiar messenger pattern, and it has a pleasant property: the&lt;br&gt;
expensive part happens once, on hardware that is already awake and already&lt;br&gt;
holding the video.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the sender's device won
&lt;/h2&gt;

&lt;p&gt;Three reasons, in the order they mattered to us.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The server could have done it, but it did not need to.&lt;/strong&gt; Shared media is&lt;br&gt;
plaintext, and encrypted chats block media entirely, so a server-side extractor&lt;br&gt;
would not have run into encrypted blobs. That removed the strongest argument&lt;br&gt;
&lt;em&gt;for&lt;/em&gt; server extraction. When both paths are technically possible, the cheaper&lt;br&gt;
one wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The code already existed.&lt;/strong&gt; The client has extracted first frames for&lt;br&gt;
pending-upload placeholders for a while — the little preview that appears in a&lt;br&gt;
bubble while a video is still in flight. Reusing that path meant the new feature&lt;br&gt;
was mostly plumbing: take the frame we already know how to take, upload it, and&lt;br&gt;
remember the URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The send path already knows how to be resilient.&lt;/strong&gt; Uploads can fail, apps can&lt;br&gt;
be killed, and a retry can happen hours later. A poster produced at send time&lt;br&gt;
fits the outbox model instead of fighting it.&lt;/p&gt;

&lt;p&gt;The trade-off is stated plainly: a poster ships with the send. Videos that were&lt;br&gt;
already in the database when this landed do not have one, so their tiles keep the&lt;br&gt;
old play-icon placeholder. That is not a bug in the renderer; it is the shape of&lt;br&gt;
the decision. The follow-up list has a server-side backfill for exactly this&lt;br&gt;
reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a poster travels from send to screen
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Extract, convert, upload
&lt;/h3&gt;

&lt;p&gt;After the video upload finishes, and before the message is delivered, the upload&lt;br&gt;
pipeline extracts frame zero, copies it into a &lt;code&gt;.jpg&lt;/code&gt; so the server's&lt;br&gt;
allowed-extension check is happy, and sends it through the normal image path. The&lt;br&gt;
resulting URL is written onto the outbox record for that message.&lt;/p&gt;

&lt;p&gt;The whole step is best-effort. If extraction fails, or the poster upload fails,&lt;br&gt;
the message still goes out — just without a thumbnail. A missing poster is a&lt;br&gt;
cosmetic loss; a blocked message is not. That is the right failure mode for chat.&lt;/p&gt;

&lt;h3&gt;
  
  
  Survive a restart
&lt;/h3&gt;

&lt;p&gt;The outbox is durable, and so is the poster URL. A restored send re-delivers the&lt;br&gt;
same poster instead of extracting and uploading it a second time. On a retry, the&lt;br&gt;
pipeline checks whether a prior attempt already produced one and skips the work.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds. Retries are the normal case on a bad&lt;br&gt;
connection, not an edge case, and re-uploading a thumbnail on every attempt would&lt;br&gt;
turn a cheap feature into a small denial-of-service against our own upload&lt;br&gt;
endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Render the tile
&lt;/h3&gt;

&lt;p&gt;In the shared-media grid, each video tile draws the poster with a scrimmed play&lt;br&gt;
badge over it. The list already knows how to lay out media tiles, including the&lt;br&gt;
grouped tiles used by &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-photo-albums-in-flutter.html" rel="noopener noreferrer"&gt;photo albums&lt;/a&gt;.&lt;br&gt;
No poster means the old placeholder — same layout, same tap target, no special&lt;br&gt;
case in the list. The badge is the only thing that says "this&lt;br&gt;
is a video"; everything else is just an image.&lt;/p&gt;

&lt;p&gt;Because the poster is a normal image, it inherits all the boring guarantees. It&lt;br&gt;
counts against upload quota, it ages out under the same TTL, and the orphan sweep&lt;br&gt;
protects it like any other upload. Forwarding a video copies the poster with it,&lt;br&gt;
so the tile looks right in the destination conversation too. When a group is&lt;br&gt;
dissolved, the poster is deleted along with the rest of that group's media.&lt;/p&gt;

&lt;p&gt;While we were in that view we also centered the &lt;strong&gt;Media / Files / Links&lt;/strong&gt;&lt;br&gt;
selector. It had been hugging the left edge on wide screens; now it centers when&lt;br&gt;
it fits and still scrolls when the width is tight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The desktop plot twist: Linux needed its own extractor
&lt;/h2&gt;

&lt;p&gt;The mobile path relies on two Flutter plugins that register Android and iOS only.&lt;br&gt;
On desktop they throw an "unimplemented" error, the client catches it, and the&lt;br&gt;
send quietly proceeds without a poster. That catch is why the feature looked fine&lt;br&gt;
on phones and simply never appeared on a Linux desktop — and why the&lt;br&gt;
pending-upload placeholder and the in-bubble preview were missing there too.&lt;/p&gt;

&lt;p&gt;The fix branches the extraction helper on the platform. On Linux it opens the&lt;br&gt;
file with a media_kit player, waits for the first decoded frame, and captures&lt;br&gt;
that frame as a muted JPEG screenshot. No separate video-decoding stack, no&lt;br&gt;
second player implementation — just a short-lived player used as a camera.&lt;/p&gt;

&lt;p&gt;One subtlety: this capture deliberately does &lt;strong&gt;not&lt;/strong&gt; take the serialization gate&lt;br&gt;
that the chat and full-screen players use. Those players call the extraction&lt;br&gt;
helper while they already hold that gate, so acquiring it again would stall the&lt;br&gt;
exact thing we were trying to preview. The helper reads a frame; it does not&lt;br&gt;
coordinate playback state.&lt;/p&gt;

&lt;p&gt;The pleasant side effect is that the branch lives in the shared thumbnail helper,&lt;br&gt;
not in the media grid. The pending placeholder and both video players get desktop&lt;br&gt;
previews at the same time, from the same change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Old videos, Windows and macOS
&lt;/h2&gt;

&lt;p&gt;Nothing changes for a video that has no poster. The renderer falls back to the&lt;br&gt;
play icon, which is what those messages showed before. The honest fix for the&lt;br&gt;
back catalogue is a server-side job that generates posters for existing rows, and&lt;br&gt;
that is on the follow-up list, not in this release.&lt;/p&gt;

&lt;p&gt;Windows and macOS also still show the icon. The desktop app ships media_kit&lt;br&gt;
libraries for Linux only — desktop video is a Linux feature today — and neither&lt;br&gt;
platform has a frame extractor wired up. Getting posters there means adding the&lt;br&gt;
platform libraries and a capture path, and we would rather do that deliberately&lt;br&gt;
than ship a half-tested branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests and what comes next
&lt;/h2&gt;

&lt;p&gt;The change is covered on both sides of the wire. On the backend, four tests&lt;br&gt;
exercise a REST send round-trip, the nullable case, the WebSocket message&lt;br&gt;
payload, and a forwarded copy that must keep its poster. On the client, six tests&lt;br&gt;
cover JSON round-trips for messages and outbox records, both when a poster is&lt;br&gt;
present and when it is absent. The absent case is the one that protects old&lt;br&gt;
messages.&lt;/p&gt;

&lt;p&gt;The follow-ups are small but worth naming:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a server-side backfill for videos sent before this change;&lt;/li&gt;
&lt;li&gt;Windows and macOS posters, once those platforms have the video stack to match;&lt;/li&gt;
&lt;li&gt;using the poster in the chat bubble's loading state and in the full-screen
player &lt;em&gt;before&lt;/em&gt; the video downloads — the data is already there;&lt;/li&gt;
&lt;li&gt;a low-quality placeholder, so the tile paints something instantly while the
real poster loads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these change the shape of the feature. They fill it in.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does every video now upload two files?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes — the video and a small JPEG. The poster is a normal image upload, subject to&lt;br&gt;
the same quota and TTL rules as any other image. If it fails, the video is still&lt;br&gt;
sent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not extract frames on the server and avoid the extra upload?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We could have: the media is plaintext, so nothing blocked it. But that adds a&lt;br&gt;
video decoder to the API image, spends server CPU on every upload, and still&lt;br&gt;
needs schema and API changes. The client already knew how to capture a frame, so&lt;br&gt;
the expensive work stays on the device that owns the video.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why do some older videos still show a play icon?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because posters ship with the send. A video that was uploaded before this change&lt;br&gt;
has no poster stored, and the renderer deliberately falls back to the placeholder&lt;br&gt;
rather than showing an empty tile. A backfill job is on the list.&lt;/p&gt;

&lt;p&gt;— the GwinG team&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>video</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Search every chat and every message from the chats list</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Sun, 18 Oct 2026 10:54:23 +0000</pubDate>
      <link>https://dev.to/frankmilvus/search-every-chat-and-every-message-from-the-chats-list-5aih</link>
      <guid>https://dev.to/frankmilvus/search-every-chat-and-every-message-from-the-chats-list-5aih</guid>
      <description>&lt;h2&gt;
  
  
  Two questions, one search box
&lt;/h2&gt;

&lt;p&gt;A search box in a messenger is usually asked two different things. The first is &lt;strong&gt;"which chat is this?"&lt;/strong&gt; — you half-remember a name, a nickname, or the last thing that was said. The second is harder: &lt;strong&gt;"where did we talk about this?"&lt;/strong&gt; — you remember the topic, not the conversation, and the line you want is months old.&lt;/p&gt;

&lt;p&gt;Most search boxes answer one and fake the other. In &lt;strong&gt;GwinG&lt;/strong&gt;, we wanted both from the same place: the chats list. The list now has a search button at the top. Tap it and a query box slides in above the chats, replacing the brand header. The menu collapses, the floating action buttons hide, and the whole page becomes one field. The back arrow, &lt;code&gt;Esc&lt;/code&gt;, or the system back button puts it away again.&lt;/p&gt;

&lt;p&gt;That sounds like a small UI change. The work was in making the two questions share one result list without lying about either. If you are new to GwinG, 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;overview post&lt;/a&gt; covers the app; this piece is about the search path, and it follows the chat-surface work in the &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-16-emoji-shortcodes-pins-and-grouped-bubbles.html" rel="noopener noreferrer"&gt;emoji, pins and grouped-bubbles write-up&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instant filtering, then a slower second opinion
&lt;/h2&gt;

&lt;p&gt;The "which chat" half is answered entirely on the device, on every keystroke. The list rows already carry what we need: a friend's name, surname, nickname and display name; a group's name; the last message preview; the draft you never sent. Pending requests match on identity fields too. None of this needs a round trip, so the list narrows as you type.&lt;/p&gt;

&lt;p&gt;The "where did we talk about this" half cannot work that way. The message you want may be far outside the handful of rows the list has loaded, so the app also asks a server-side message search endpoint. That call is &lt;strong&gt;debounced by 300 ms&lt;/strong&gt;, so a fast typist does not fire one request per letter. A sequence guard drops stale responses: if an older query resolves after a newer one, its results are ignored instead of briefly overwriting the correct list.&lt;/p&gt;

&lt;p&gt;The two layers merge into one view. While the server is still thinking, local matches stay on screen and a spinner sits where remote results will land. When there is nothing at all, the page shows a single "No chats found" state rather than two competing empty messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server actually searches
&lt;/h2&gt;

&lt;p&gt;The message search is deliberately narrow, because "search everything" is the wrong default when part of the history is encrypted.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only looks at conversations the caller belongs to.&lt;/li&gt;
&lt;li&gt;It skips conversations marked encrypted, and skips deleted messages.&lt;/li&gt;
&lt;li&gt;It matches the query as a case-insensitive substring of message content.&lt;/li&gt;
&lt;li&gt;It returns newest first, with a default cap of 50 hits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each hit arrives with enough context to render a row without another request: the conversation it belongs to, its title, the sender's identity, and the timestamp. A group sender is included for a practical reason — the client cannot resolve a member's display name from a message payload alone, so the server resolves it while it already has the row.&lt;/p&gt;

&lt;p&gt;The hit payload reuses the same serializer as an ordinary message. That is a quiet but important decision: a search result carries the same reaction, reply and link-preview fields as the message does in the conversation, so the row can show what the message actually is instead of a stripped-down copy that drifts from the real thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Messages above chats
&lt;/h2&gt;

&lt;p&gt;Results are grouped into two sections, and the order is intentional: &lt;strong&gt;Messages&lt;/strong&gt; first, then &lt;strong&gt;Chats&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A message row shows the chat title, &lt;code&gt;sender: snippet&lt;/code&gt;, a group badge when relevant, and the date. It is a direct answer to "where did we talk about this", so it gets the top of the page.&lt;/p&gt;

&lt;p&gt;The Chats section is the union of two matches. A row stays when its name, nickname, last-message preview or draft matches &lt;strong&gt;or&lt;/strong&gt; when any server hit belongs to that conversation. That second clause is what makes the two layers feel like one feature. A chat whose most recent message is "see you tomorrow" still appears when your query matches something from three months ago — the preview would never have surfaced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opening a hit exactly where it happened
&lt;/h2&gt;

&lt;p&gt;Tapping a message result does not just open the conversation. It opens it &lt;strong&gt;at that message&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The hit carries an initial message id into the chat screen. After the controller finishes its normal initialization, the view tries to scroll to that id. If the message is not in the first page the chat loaded, it fetches a context window around it first, then scrolls and highlights it. Most chats open pinned to the newest message; this is the one path that deliberately opens in the middle of history, and it has to work when the target is far from the bottom.&lt;/p&gt;

&lt;p&gt;The point is not the animation but the position: you land with the matching line in view and the surrounding messages above and below, which is usually what you need to remember why you were looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  One search box, one visible tab
&lt;/h2&gt;

&lt;p&gt;The chats list has tabs, and each tab keeps its own membership. Search respects that. Both the local rows and the message hits are scoped to the tab you are looking at.&lt;/p&gt;

&lt;p&gt;That is a deliberate constraint rather than an oversight. Merging every tab into one result list would produce hits you cannot act on without first guessing which tab owns them. Scoping keeps the mental model simple: search narrows the list in front of you. If the conversation lives in another tab, switch tabs and search there.&lt;/p&gt;

&lt;p&gt;The same scoping rule keeps a message hit from resurrecting a chat that the current tab would otherwise hide. A hit can keep its own chat row visible, but only when that row belongs to the visible tab in the first place. The new interface strings ship in all six supported locales, so the two sections and the empty state read the same way everywhere the app does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Escape the wildcards
&lt;/h2&gt;

&lt;p&gt;The message search uses a &lt;code&gt;LIKE&lt;/code&gt;-style substring match, and that syntax has two characters with special meaning: &lt;code&gt;%&lt;/code&gt; matches any run of characters, and &lt;code&gt;_&lt;/code&gt; matches any single one. Left alone, a query like &lt;code&gt;100%&lt;/code&gt; or &lt;code&gt;snake_case&lt;/code&gt; stops being a literal search and starts behaving like a pattern.&lt;/p&gt;

&lt;p&gt;We added a small escaping step before the query reaches the database, and declared the escape character on the pattern itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="k"&gt;ESCAPE&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query's &lt;code&gt;%&lt;/code&gt;, &lt;code&gt;_&lt;/code&gt; and the escape character are escaped first, so the only wildcards in the final pattern are the ones the server adds around the search term. We applied the same treatment to the existing per-conversation message search, which had the same flaw. It is a small fix with a visible payoff: searching for a literal percent sign finds the message that contains one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do yet
&lt;/h2&gt;

&lt;p&gt;Two limits are worth stating plainly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encrypted chats are not message-searchable.&lt;/strong&gt; The server only ever sees ciphertext there, so it cannot match content it cannot read. Those conversations still match on identity and on their last-message preview, but they will never contribute a Messages hit. Searching the decrypted cache on the device is the eventual path; it is not in this version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results are capped and ranked by recency.&lt;/strong&gt; The default cap is 50, and ordering is newest first. This is a substring search, not a tokenized full-text index, so it does not rank by relevance or understand word stems. For the common case — "I know roughly what was said, show me the recent candidates" — recency is the right default and the cheapest thing to reason about.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does search find messages older than the preview in the chats list?&lt;/strong&gt;&lt;br&gt;
Yes. The preview only reflects the newest message. The Messages section comes from a server search over message history, so an old line can surface a conversation whose preview says nothing about your query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why do encrypted chats never appear in the Messages section?&lt;/strong&gt;&lt;br&gt;
Because the server stores and serves ciphertext for those conversations. It can match the chat's identity and its encrypted preview locally, but it has no readable content to search. That is a property of the encryption model, not a missing index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the search case-sensitive, and can I search for a literal &lt;code&gt;%&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
It is case-insensitive. &lt;code&gt;%&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt; are escaped before the query runs, so they are treated as ordinary characters rather than wildcards.&lt;/p&gt;

&lt;p&gt;— the GwinG team&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>postgres</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Pin flights, a real find hotkey, and colored More tiles</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Sun, 18 Oct 2026 10:53:41 +0000</pubDate>
      <link>https://dev.to/frankmilvus/pin-flights-a-real-find-hotkey-and-colored-more-tiles-774</link>
      <guid>https://dev.to/frankmilvus/pin-flights-a-real-find-hotkey-and-colored-more-tiles-774</guid>
      <description>&lt;h2&gt;
  
  
  Small changes you feel before you name them
&lt;/h2&gt;

&lt;p&gt;Most of the work in a messenger is invisible: delivery state, encryption, reconnects, the thing that happens when the network drops halfway through a photo. Every now and then a batch of much smaller changes lands, and the app simply &lt;em&gt;feels&lt;/em&gt; different. This is one of those batches.&lt;/p&gt;

&lt;p&gt;Three things changed in GwinG this week. Pinning a chat or a message now has a short flight instead of a snap. The browser-standard find chord works on every search surface. And the three navigation tiles on the More tab picked up an accent color from the active theme.&lt;/p&gt;

&lt;p&gt;None of them adds a feature you can point at in a changelog title. All of them remove a tiny moment of doubt.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pin should move, not teleport
&lt;/h2&gt;

&lt;p&gt;When you pin a chat, the list reorders immediately. That is the correct data behaviour, but it used to be a hard cut: the row you were looking at vanished from one place and appeared in another, and your eye had to find it again.&lt;/p&gt;

&lt;p&gt;The new pin flight keeps the row legible. Before the model reorders the list, the UI snapshots the selected row's rectangle. One frame later — after the reordered list has laid out — a ghost tile flies from the old rectangle to the row's new position in 320 ms. The real row is hidden at its destination while the ghost is in the air, so there is never a double image. The ghost's avatar hero is disabled so it cannot collide with the real row's hero.&lt;/p&gt;

&lt;p&gt;The details are where the edge cases live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The destination may be off-screen.&lt;/strong&gt; If the pinned section scrolled out of view, the ghost lands at the list edge — top for a pin, bottom for an unpin — and fades out. The pin still applies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second pin may arrive mid-flight.&lt;/strong&gt; The old ghost is cancelled before a new one starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The optimistic model update may not reach the expected state.&lt;/strong&gt; In that case the flight is skipped; the list simply reorders as before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same chat can be mounted in several kept-alive tab pages.&lt;/strong&gt; The row keys are namespaced per tab, so the flight finds the exact mounted row rather than a lookalike in another tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pinned messages get the same treatment in the other direction. The message bubble measures a compact source rectangle, and the pinned bar is measured one frame after it mounts. A ghost — pin icon plus the same preview text — flies from the bubble to the bar in 340 ms and fades over its last fifth, revealing the real bar underneath. A missing source rectangle or a squeezed bar (the keyboard can do that) skips the flight; the pin still applies. Unpinning deliberately does not animate.&lt;/p&gt;

&lt;p&gt;The whole thing is behind a new &lt;strong&gt;Settings → Motion → Pin animation&lt;/strong&gt; toggle. It is on by default and follows the same rule as the other motion work: it only plays at the Full motion level. Reduced or Off means the old instant reorder, which is still perfectly usable.&lt;/p&gt;

&lt;h3&gt;
  
  
  The entrance bug hiding in the same pass
&lt;/h3&gt;

&lt;p&gt;While adding the flights we fixed a bug that had been making list entrances look wrong. The staggered entrance used a &lt;code&gt;SlideTransition&lt;/code&gt; with an offset of &lt;code&gt;18&lt;/code&gt;. &lt;code&gt;SlideTransition&lt;/code&gt; treats that number as a &lt;strong&gt;fraction of the child's size&lt;/strong&gt;, not a pixel count. Rows were starting roughly eighteen times their own height below their final position. The fix was to use a &lt;code&gt;Transform.translate&lt;/code&gt; in logical pixels, so the rows now move 18 px.&lt;/p&gt;

&lt;p&gt;The second half of the fix was about replays. Rows are disposed when they scroll far out of view, so a keyed entrance could replay when they came back. Each row now animates only on its first build for the lifetime of the Friends view. Rows revealed by keyboard navigation still skip the entrance on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The find chord should mean find
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;main+F&lt;/code&gt; is the browser-standard find chord. On GwinG it is &lt;code&gt;Alt+F&lt;/code&gt; by default, or &lt;code&gt;Ctrl+F&lt;/code&gt; if you choose the alternative in Settings → Shortcuts. Until now it only worked in some places, which made it feel unreliable: a shortcut that works &lt;em&gt;sometimes&lt;/em&gt; is worse than no shortcut, because you stop trusting it.&lt;/p&gt;

&lt;p&gt;It now focuses the search field on every search surface:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Friends chats list&lt;/td&gt;
&lt;td&gt;Opens the in-place search box and focuses it. Pressing F again re-focuses the field and selects the current query. Escape still closes the box.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-chat message search&lt;/td&gt;
&lt;td&gt;Opens the search panel and focuses its field. While the panel is open, its own handler owns F so the query can be re-focused and selected.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search tab&lt;/td&gt;
&lt;td&gt;Alias of the existing focus chord, and F additionally selects the current query.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contacts&lt;/td&gt;
&lt;td&gt;Focuses the friend filter above the list.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create Group → Members&lt;/td&gt;
&lt;td&gt;Focuses the friend picker; inert on the other steps.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Group info → Add members&lt;/td&gt;
&lt;td&gt;Re-focuses the friend picker in the add-members sheet.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two implementation details made the difference. On the chats list, the F check runs &lt;em&gt;before&lt;/em&gt; the "the search field already has focus" early return, so the chord also works while you are typing. In the chat page, the search panel registers its own keyboard handler while it is mounted, because the chat page's shortcut scope is inactive when the panel is on top.&lt;/p&gt;

&lt;p&gt;No new localization keys were needed. The help rows reuse the existing strings for "Search chats", "Search in chat", and "Focus the search box", so the cheat sheet stays consistent without translating the same idea twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Color as a signpost
&lt;/h2&gt;

&lt;p&gt;The More tab has three navigation tiles: Settings, My Space, and Switch account. They were all the same default icon color. Now Settings uses the &lt;code&gt;secondary&lt;/code&gt; color, My Space uses &lt;code&gt;tertiary&lt;/code&gt;, and Switch account uses &lt;code&gt;primary&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The palette is resolved once from the active theme, so the tiles follow light/dark mode and the user's accent preset. There is no behaviour change, no new copy, and no server sync. It is a small piece of wayfinding: three destinations, three visual anchors, one less moment of reading the labels.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does pinning still work if motion is disabled?
&lt;/h3&gt;

&lt;p&gt;Yes. The data change is independent of the animation. With motion off, the row or message simply moves to its new place without the ghost flight.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;Alt+F&lt;/code&gt; work while I am typing in the search box?
&lt;/h3&gt;

&lt;p&gt;Yes. On the chats list the chord is checked before the focus check, and it selects the current query so you can replace it immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do the More tile colors change with the theme?
&lt;/h3&gt;

&lt;p&gt;Yes. They are resolved from the active theme palette, so they follow the light/dark mode and accent preset rather than being fixed colors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this batch matters
&lt;/h2&gt;

&lt;p&gt;A language-exchange app is a place where people already have to work a little: find the right person, switch languages, read a translation, check a pronunciation. The interface should not add its own small uncertainties on top of that.&lt;/p&gt;

&lt;p&gt;A pin that flies is not a feature. A find chord that works everywhere is not a feature. A colored tile is not a feature. But together they make the app feel like it is paying attention — and that is the point of polish work.&lt;/p&gt;

&lt;p&gt;If you want the implementation-level companion to this piece, the &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-shared-media-gallery-in-flutter.html" rel="noopener noreferrer"&gt;shared media gallery&lt;/a&gt; and &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-photo-albums-in-flutter.html" rel="noopener noreferrer"&gt;photo albums&lt;/a&gt; write-ups cover the same "small detail, real feel" territory.&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>opensource</category>
    </item>
    <item>
      <title>Why we renamed tonGues to GwinG — and what stayed the same</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Sun, 18 Oct 2026 10:53:40 +0000</pubDate>
      <link>https://dev.to/frankmilvus/why-we-renamed-tongues-to-gwing-and-what-stayed-the-same-2968</link>
      <guid>https://dev.to/frankmilvus/why-we-renamed-tongues-to-gwing-and-what-stayed-the-same-2968</guid>
      <description>&lt;h2&gt;
  
  
  Why this post exists
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GwinG&lt;/strong&gt; is the new name of &lt;strong&gt;tonGues&lt;/strong&gt; — the same language-exchange messenger, with the same accounts, conversations and encryption. The change you will notice is on the surface: the name, the wordmark, the tagline, the splash screen and the desktop builds. The change you should &lt;em&gt;not&lt;/em&gt; notice is underneath: your sign-in, your history, your keys and the links you have already shared stay where they are.&lt;/p&gt;

&lt;p&gt;If you are new to the project, 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;overview of GwinG&lt;/a&gt; is the right place to start. This post is about the rename itself: why the old name had to go, how we chose a new one, and the line we drew between the visible brand and the things that must never move.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old name was already taken
&lt;/h2&gt;

&lt;p&gt;The short reason for the rename is that there was already a language app using the same name. Not a similar name, not a name in the same category — the same word.&lt;/p&gt;

&lt;p&gt;That is not a scandal, and it is not unusual for a small project. It is a practical dead end. A name is how people find you in a store search, how they recommend you in a sentence, and how they tell a friend which app to install. When two products answer to the same word, every one of those moments turns into a clarification: the right app, by the right developer, not the other one. For a project built around conversation, spending the first minute of every conversation explaining your name is a bad trade.&lt;/p&gt;

&lt;p&gt;There was a second problem, smaller but persistent. The old tagline was a pun — “set tonGues wagging” — and a pun only works when the name is already familiar. It also depended on a spelling that is easy to fumble in a search box. Once the name had to stand on its own, the wordplay was carrying more weight than the word.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a name has to do when it is also a handle
&lt;/h2&gt;

&lt;p&gt;For an app, a name has four jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Be found.&lt;/strong&gt; Someone types it into a store search and sees the right result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be said.&lt;/strong&gt; Someone recommends it out loud without spelling it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be typed.&lt;/strong&gt; Someone enters it into a message without autocorrect fighting back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be owned.&lt;/strong&gt; A search for it points at this project, not at a dictionary entry or a different product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The old name was decent at being said and typed. It failed the first and fourth jobs, and those are the ones that compound. Every new user who cannot find you is a user you never hear from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing a name we could own
&lt;/h2&gt;

&lt;p&gt;We wanted a coined name rather than a borrowed one. &lt;strong&gt;GwinG&lt;/strong&gt; is not a dictionary word. It is five letters with a capital at each end, and that shape is the whole design: two bookends around a short, plain middle.&lt;/p&gt;

&lt;p&gt;A coined name does something a common noun cannot — it makes the search result unambiguous. It also gives the wordmark a natural structure. The two &lt;code&gt;G&lt;/code&gt;s are the edges, so they can carry the strongest colour, while the middle can recede.&lt;/p&gt;

&lt;p&gt;We kept the name short enough to sit in an app bar without shrinking, and distinct enough to be a handle. That last point mattered more than elegance. A name you have to qualify is a name that never quite becomes yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wordmark: red at the edges, amber at the centre
&lt;/h2&gt;

&lt;p&gt;The wordmark was rebuilt around that shape. Both &lt;code&gt;G&lt;/code&gt;s are accented, and every letter is coloured by its distance to the nearest accent. The two &lt;code&gt;G&lt;/code&gt;s sit at the ends, so they take the red end of the palette. The &lt;code&gt;win&lt;/code&gt; between them blends through orange and lands on amber at the centre.&lt;/p&gt;

&lt;p&gt;That is the rule in one sentence: &lt;strong&gt;red at the accented edges, amber at the centre&lt;/strong&gt;. It reads as one word rather than two initials, and it uses the palette the app already had — the same red and amber, not a new set of colours bolted on for the occasion.&lt;/p&gt;

&lt;p&gt;The change is deliberately local to the wordmark widget. Strings with a single capital still render the way they did before, so the same component keeps working on screens that are not the login page. In practice you see it on the login screen, the friends header, the More tab and the in-app splash — the places where the app says its own name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tagline: where languages meet
&lt;/h2&gt;

&lt;p&gt;The old tagline had to go with the old name, because the joke lived inside the word. The new one is &lt;strong&gt;where languages meet&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It is shorter, it does not depend on a pun, and it describes the product without naming a language or a direction. Two people can meet in the middle; neither has to switch to the other's language to start. That is the idea the app is built around, and the tagline now says it in four words.&lt;/p&gt;

&lt;p&gt;It is localized alongside the rest of the interface — German, Spanish, French, Korean and Russian, with English as the source — so the line is not an English-only slogan pasted onto a translated app.&lt;/p&gt;

&lt;h2&gt;
  
  
  A new splash with the same flame
&lt;/h2&gt;

&lt;p&gt;The splash image was redrawn with the new wordmark, keeping the flame, the palette and the bold monospace styling. The native splash assets were regenerated for Android, iOS and web, so the first frame of the app matches the in-app splash instead of showing the old name for a beat.&lt;/p&gt;

&lt;p&gt;The launcher icon is intentionally unchanged. It is a textless flame, and it never depended on the name. A new icon is a separate design decision, not something to smuggle into a rename at the last minute. The tool that generates the splash was also updated, so regenerating the assets reproduces the new wordmark instead of quietly restoring the old one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Desktop builds get a new name
&lt;/h2&gt;

&lt;p&gt;The desktop builds were renamed from &lt;code&gt;tongues&lt;/code&gt; to &lt;code&gt;gwing&lt;/code&gt;. The binary, the packaged artifacts, the window title and the installer display name now say GwinG. In day-to-day use that means a &lt;code&gt;gwing&lt;/code&gt; binary, &lt;code&gt;gwing&lt;/code&gt; packages, and a GwinG entry in the launcher instead of the old one.&lt;/p&gt;

&lt;p&gt;Upgrades were the part that needed care. The installers clean up the old executable and shortcuts, and the Linux package declares that it replaces the old one instead of sitting beside it. The previous &lt;code&gt;tongues-*&lt;/code&gt; downloads remain available for one release cycle, so nobody is stranded mid-upgrade.&lt;/p&gt;

&lt;p&gt;The important detail is invisible: the desktop storage identity was left exactly as it was. The executable changed; the data it opens did not. That is what keeps preferences, the secure store and the encryption identity intact across an upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not change
&lt;/h2&gt;

&lt;p&gt;This is the list we wrote down before touching anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accounts and sessions.&lt;/strong&gt; Your account is the same account. There is no sign-out, no re-link and no migration step. If you use saved accounts, they are still saved — the &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-17-fast-account-switching-in-flutter.html" rel="noopener noreferrer"&gt;account-switching write-up&lt;/a&gt; explains how that isolation works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package identity.&lt;/strong&gt; The installed app and the store listing are the same product. Updates arrive as updates, not as a second app with a new identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted chats.&lt;/strong&gt; Encryption was not touched. The encryption identity and keys stay the same, so existing encrypted history remains readable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deep links.&lt;/strong&gt; The &lt;code&gt;tongue://&lt;/code&gt; scheme is unchanged, so links already shared in chats, notes or bookmarks keep resolving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notification channels.&lt;/strong&gt; The identifiers are unchanged; only the display names now say GwinG. Per-channel sound, vibration and mute settings survive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local data.&lt;/strong&gt; Theme choice, saved accounts, cached media and local history keep their storage keys, so an upgrade does not orphan them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Article URLs.&lt;/strong&gt; Existing posts keep their slugs and canonical addresses. Bookmarks and inbound links do not break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Historical records.&lt;/strong&gt; Old changelogs and plans still say tonGues. They are records of what was true then, and rewriting them would make the history less accurate, not more.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is simple: &lt;strong&gt;the name is allowed to change; the identity is not.&lt;/strong&gt; A rebrand that quietly changes identity is not a rebrand. It is a new app wearing an old name, and users pay for it with a sign-in screen and a missing history.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is GwinG a different app from tonGues?&lt;/strong&gt;&lt;br&gt;
No. It is the same app under a new visible name. Your account, friends, conversations and media are the ones you already had.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will I have to sign in again or lose my encrypted history?&lt;/strong&gt;&lt;br&gt;
No. The rename does not touch accounts, credentials or the encryption identity. On desktop, the storage identity was kept specifically so an upgrade finds the same preferences, secure store and keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do old links and notification settings still work?&lt;/strong&gt;&lt;br&gt;
Yes. The deep-link scheme is unchanged, so previously shared links keep resolving. Notification channel identifiers are unchanged too, so per-channel settings survive; only the display names now say GwinG.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test we cared about
&lt;/h2&gt;

&lt;p&gt;A rename is easy to make loud and hard to make safe. The visible parts — the wordmark, the tagline, the splash, the desktop artifacts — are the parts people will notice. The invisible parts are the ones that decide whether the change feels like an update or like a reset.&lt;/p&gt;

&lt;p&gt;Our test was simple: if you had a saved account, a shared link, a muted channel and an encrypted conversation from before the rename, all four should still be there afterwards. The name changed. Everything that makes the app yours did not.&lt;/p&gt;

&lt;p&gt;— the GwinG team&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>languageexchange</category>
      <category>branding</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Fast account switching in Flutter without a sign-in round trip</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Sat, 17 Oct 2026 13:45:51 +0000</pubDate>
      <link>https://dev.to/frankmilvus/fast-account-switching-in-flutter-without-a-sign-in-round-trip-2com</link>
      <guid>https://dev.to/frankmilvus/fast-account-switching-in-flutter-without-a-sign-in-round-trip-2com</guid>
      <description>&lt;p&gt;Most messenger accounts are single-account by design: you sign in, the session&lt;br&gt;
lives in one token slot, and every local cache is global. That works until a&lt;br&gt;
real user needs two accounts on the same device — a personal profile and a&lt;br&gt;
work one, or two language-learning identities.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;GwinG&lt;/strong&gt;, a Flutter language-exchange messenger, we added &lt;strong&gt;saved&lt;br&gt;
accounts&lt;/strong&gt;: the app remembers up to five accounts and switches between them in&lt;br&gt;
one tap, without a browser or a Google round trip. The interesting part was not&lt;br&gt;
the picker. It was making "switch account" safe — the new session has to start&lt;br&gt;
clean, and none of the previous account's data may leak into it.&lt;/p&gt;

&lt;p&gt;This post covers the UX we landed on, the session lifecycle, and the isolation&lt;br&gt;
patterns that made fast switching reliable.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the user sees
&lt;/h2&gt;

&lt;p&gt;Saved accounts are listed on the login screen above the sign-in button, and in&lt;br&gt;
an account switcher reachable from three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;long-press&lt;/strong&gt; the profile avatar in the navbar (right-click on desktop);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Alt+0&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;Ctrl+0&lt;/code&gt; if the user chose Ctrl as the main modifier);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More → Switch account&lt;/strong&gt;, next to Log out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The switcher can switch to a saved account, add another one, remove a single&lt;br&gt;
account, or sign out of everything. After a successful switch the app opens a&lt;br&gt;
fresh home shell. That detail matters: the tab pages live in an &lt;code&gt;IndexedStack&lt;/code&gt;,&lt;br&gt;
so reusing the shell would keep the previous account's friends, chats and&lt;br&gt;
search results mounted — and visible for a frame. Rebuilding the shell disposes&lt;br&gt;
them instead.&lt;/p&gt;

&lt;p&gt;Up to five accounts are remembered, most-recently-used first. Adding a sixth&lt;br&gt;
evicts the least recently used account that is not the one being activated, and&lt;br&gt;
removes its local data.&lt;/p&gt;
&lt;h2&gt;
  
  
  Validate before you tear down
&lt;/h2&gt;

&lt;p&gt;The tempting implementation is: read the target's token, drop the current&lt;br&gt;
session, then validate. It is also the one that hurts users. A dropped Wi-Fi&lt;br&gt;
connection at the wrong moment leaves them signed out of both accounts.&lt;/p&gt;

&lt;p&gt;The rule we use is the reverse: &lt;strong&gt;validate the target completely, then teardown,&lt;br&gt;
then publish&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;saved account tapped
        │
        ├─ access token still valid? ──no──► refresh the session
        │                                      │
        │                                      └─ rejected? → remove entry, show error
        ▼
  confirm the identity with the backend
        │
        ├─ network failure → keep the current session untouched
        └─ valid            → tear down → publish → reconnect
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A network failure leaves the user exactly where they were. Only a definitive&lt;br&gt;
rejection removes the saved entry and asks for a fresh sign-in.&lt;/p&gt;

&lt;p&gt;The teardown itself is deliberately boring: end any active call, stop presence&lt;br&gt;
polling, dispose the WebSocket, cancel background prefetchers, reset cached&lt;br&gt;
encryption identity state, then clear in-memory tokens and notifiers. The&lt;br&gt;
target's persisted data is &lt;strong&gt;not&lt;/strong&gt; deleted by a switch.&lt;/p&gt;
&lt;h2&gt;
  
  
  One session at a time
&lt;/h2&gt;

&lt;p&gt;Two switches interleaving is a surprisingly easy bug to ship. We learned the&lt;br&gt;
hard way that a "switch in progress" flag must be claimed &lt;strong&gt;before the first&lt;br&gt;
network await&lt;/strong&gt;, not after validation. Validation can take several round trips;&lt;br&gt;
a second tap during that window otherwise passes the guard too.&lt;/p&gt;

&lt;p&gt;We also stamp each session with an epoch. Late async work compares its captured&lt;br&gt;
epoch with the current one and drops the result if the session moved on. That&lt;br&gt;
covers the usual suspects — a profile fetch that finishes after a switch, a&lt;br&gt;
token refresh that belongs to the previous account, a background cache refresh&lt;br&gt;
that was already in flight.&lt;/p&gt;

&lt;p&gt;There is a second half to the token-refresh case: the rotated token still&lt;br&gt;
belongs to the account that requested it. We read the account id from the&lt;br&gt;
refresh token itself and persist the result in that account's slot, never in&lt;br&gt;
"whichever account is active now." A request that started under account A may&lt;br&gt;
finish under account B, but it must not write into B.&lt;/p&gt;
&lt;h2&gt;
  
  
  Scoping on-device data by account
&lt;/h2&gt;

&lt;p&gt;This is where most of the work lives. A messenger accumulates a lot of local&lt;br&gt;
state: messages, translations, pinned messages, chat tabs, drafts, encryption&lt;br&gt;
keys, the upload outbox, friends. If any of it is keyed globally, the second&lt;br&gt;
account sees the first account's data.&lt;/p&gt;

&lt;p&gt;Every store now includes the account id in its key or path:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Store&lt;/th&gt;
&lt;th&gt;Scoped form&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Message cache&lt;/td&gt;
&lt;td&gt;one database per account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Translations / transcriptions&lt;/td&gt;
&lt;td&gt;key suffix per account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pinned messages&lt;/td&gt;
&lt;td&gt;account + conversation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversation freshness markers&lt;/td&gt;
&lt;td&gt;account + conversation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chat tabs&lt;/td&gt;
&lt;td&gt;account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encryption keys&lt;/td&gt;
&lt;td&gt;account prefix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drafts and upload outbox&lt;/td&gt;
&lt;td&gt;already per account&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fallback segment is &lt;code&gt;_shared&lt;/code&gt;, used only before a session exists. In normal&lt;br&gt;
use there is always an account.&lt;/p&gt;

&lt;p&gt;Encryption keys are the critical case. Sharing an identity between accounts&lt;br&gt;
would present the same encryption identity to peers for two different users and&lt;br&gt;
corrupt ratchet state on both sides. The cached identity is dropped on a switch,&lt;br&gt;
and the next key operation reloads the target account's keys.&lt;/p&gt;
&lt;h2&gt;
  
  
  Keeping async work on its account
&lt;/h2&gt;

&lt;p&gt;Keying the stores by account is necessary but not sufficient. The subtle bug is&lt;br&gt;
&lt;strong&gt;when&lt;/strong&gt; the account is resolved. A cache write often happens after network&lt;br&gt;
awaits:&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;account&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AccountScope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;segment&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;fetched&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;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fetchMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversationId&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;AccountScope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;segment&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;account&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="c1"&gt;// the session moved on&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;MessageCacheService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetched&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;segment:&lt;/span&gt; &lt;span class="n"&gt;account&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 make this reliable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The account is captured &lt;strong&gt;once&lt;/strong&gt;, before the first await.&lt;/li&gt;
&lt;li&gt;The result is dropped if the active account changed in the meantime.&lt;/li&gt;
&lt;li&gt;The cache API accepts the captured segment instead of re-reading the global
state at write time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For encryption operations we go one step further and freeze the account for the&lt;br&gt;
whole operation, across every nested await. Dart zones are a neat fit: the&lt;br&gt;
operation runs in a child zone that carries the account, and key-store reads and&lt;br&gt;
writes resolve that captured value instead of the live session.&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;static&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;body&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;captured&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;segment&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;runZoned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;zoneValues:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;_operation:&lt;/span&gt; &lt;span class="n"&gt;captured&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;segment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Zone&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_operation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="n"&gt;_userId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s"&gt;'_shared'&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 the difference between "the key store is usually scoped" and "an&lt;br&gt;
encrypt that started before a switch can never write into the next account."&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating the single-account past
&lt;/h2&gt;

&lt;p&gt;Existing installs had exactly one session: un-prefixed tokens, one message&lt;br&gt;
database, one encryption identity, one tab layout. Updating must not log those&lt;br&gt;
users out or orphan their encrypted history.&lt;/p&gt;

&lt;p&gt;The migration runs only when the app has proven which user the legacy session&lt;br&gt;
belongs to. It copies the tokens into that user's scoped slots, moves the&lt;br&gt;
encryption keys and message cache into the same namespace, and then deletes the&lt;br&gt;
legacy copies.&lt;/p&gt;

&lt;p&gt;The important safety rule: a legacy layout is never claimed by "whichever&lt;br&gt;
account loads first." If the old session cannot be restored at launch and the&lt;br&gt;
user signs in manually, the app still migrates the legacy data to its real&lt;br&gt;
owner and clears the old token slots — so a later launch can never silently&lt;br&gt;
restore the wrong identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three bugs a review pass caught
&lt;/h2&gt;

&lt;p&gt;The tests were green and the analyzer was clean, but a behavioural review still&lt;br&gt;
found three isolation defects worth sharing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A partial secure-store read looked like a complete session.&lt;/strong&gt; One token key
was present and the other was not, so the read path skipped the fallback and
returned a session with no refresh token. Fix: complete only the missing half
from the fallback, and add a test for the mixed state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache writes resolved the account at completion time.&lt;/strong&gt; A prefetch that
started under account A could finish after a switch and write into account
B's database. Fix: capture the account at the start and pass it through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A legacy layout was claimed by the first account to load.&lt;/strong&gt; That exposed the
previous user's tab names and chosen contacts. Fix: migrate legacy data only
on the proven legacy path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are compiler errors. They are the class of bug that only shows up&lt;br&gt;
when a real user switches accounts at an awkward moment.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Validate the destination before ending the current session.&lt;/strong&gt; Users forgive
a slow switch; they do not forgive being signed out of both accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capture ambient state at the start of async work.&lt;/strong&gt; "Current account" is
ambient state, and it can change under you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the account an explicit parameter at the storage boundary.&lt;/strong&gt; It turns a
silent cross-account write into a compile-time argument.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat legacy data as owned by a proven user.&lt;/strong&gt; Never let a random first load
claim it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the mixed and interrupted states&lt;/strong&gt;, not just the happy path: partial
storage, a switch mid-fetch, a switch while a key operation is running.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Does this work on desktop?&lt;/strong&gt;&lt;br&gt;
Yes. Long-press or right-click the avatar, or press &lt;code&gt;Alt+0&lt;/code&gt;. The same switcher&lt;br&gt;
is available from the More tab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do inactive accounts receive notifications?&lt;/strong&gt;&lt;br&gt;
No. Push notifications follow the active account on that device. Switching back&lt;br&gt;
restores its session and its pending sends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to local data when I log out?&lt;/strong&gt;&lt;br&gt;
A plain log-out removes the current account's tokens and its entry from the&lt;br&gt;
picker, but keeps its namespaced local data so signing back in later restores the&lt;br&gt;
encrypted history. Removing an account, or signing out of all accounts, deletes&lt;br&gt;
that account's local data as well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does a saved account stay usable?&lt;/strong&gt;&lt;br&gt;
As long as its refresh session is valid. If it expires, the picker asks you to&lt;br&gt;
sign in again for that account instead of failing silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why only five accounts?&lt;/strong&gt;&lt;br&gt;
It covers the realistic shared-device and personal/work cases while keeping the&lt;br&gt;
picker and the local storage footprint small. The limit is a constant, not a&lt;br&gt;
backend restriction.&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>flutter</category>
      <category>dart</category>
      <category>showdev</category>
      <category>languageexchange</category>
    </item>
    <item>
      <title>Inline emoji shortcodes, private pins, and grouped bubbles</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Fri, 16 Oct 2026 13:16:09 +0000</pubDate>
      <link>https://dev.to/frankmilvus/inline-emoji-shortcodes-private-pins-and-grouped-bubbles-4910</link>
      <guid>https://dev.to/frankmilvus/inline-emoji-shortcodes-private-pins-and-grouped-bubbles-4910</guid>
      <description>&lt;p&gt;This week in &lt;strong&gt;GwinG&lt;/strong&gt;, a Flutter chat app for language exchange backed by a&lt;br&gt;
FastAPI + PostgreSQL server, we shipped a batch of chat work: an inline&lt;br&gt;
&lt;strong&gt;:shortcode emoji autocomplete&lt;/strong&gt; in the composer, a per-user &lt;strong&gt;"pin for me"&lt;/strong&gt;&lt;br&gt;
that sits alongside the existing conversation-wide pin, and &lt;strong&gt;sender grouping&lt;/strong&gt;&lt;br&gt;
for message bubbles so a burst of messages from one person reads as a block.&lt;/p&gt;

&lt;p&gt;We also ran a behavioural review pass over the whole batch before shipping it.&lt;br&gt;
That pass is the interesting part: the tests were green and the analyzer was&lt;br&gt;
clean, yet it caught three defects that would have shipped silently. I'll show&lt;br&gt;
those at the end, because they're the reason we do the pass at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  Inline emoji shortcodes: &lt;code&gt;:joy&lt;/code&gt; → 😂
&lt;/h2&gt;

&lt;p&gt;People type &lt;code&gt;:joy&lt;/code&gt;, &lt;code&gt;:thumbsup&lt;/code&gt;, &lt;code&gt;:tada&lt;/code&gt; — the aliases that grew up around chat&lt;br&gt;
apps — not the formal Unicode names. So the composer now watches the token under&lt;br&gt;
the caret and offers matching emoji in a horizontal strip just above the input.&lt;br&gt;
Tap a cell, or press Enter, and the token is replaced with the glyph.&lt;/p&gt;

&lt;p&gt;The table is GitHub's gemoji set — &lt;strong&gt;1,913 aliases&lt;/strong&gt; — bundled as a JSON asset&lt;br&gt;
so matching works offline. That's deliberate: the familiar shorthand (&lt;code&gt;joy&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;thumbsup&lt;/code&gt;, &lt;code&gt;tada&lt;/code&gt;) simply isn't in Unicode's own names, so a pure CLDR-prefix&lt;br&gt;
lookup would miss exactly the shortcodes people reach for.&lt;/p&gt;
&lt;h3&gt;
  
  
  A token scanner that stays out of the way
&lt;/h3&gt;

&lt;p&gt;The hard part of inline autocomplete is not the lookup — it's deciding &lt;em&gt;when&lt;/em&gt;&lt;br&gt;
there is a token at all. A scanner that fires too eagerly pops the strip while&lt;br&gt;
you type a URL or a clock time. The rule set we settled on is deliberately&lt;br&gt;
conservative:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the caret must be &lt;strong&gt;collapsed&lt;/strong&gt; — a range selection has no single token;&lt;/li&gt;
&lt;li&gt;the token is &lt;code&gt;:&lt;/code&gt; followed by &lt;strong&gt;at least one&lt;/strong&gt; shortcode character before the
caret, so a bare &lt;code&gt;:&lt;/code&gt; or a trailing space ends it;&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;:&lt;/code&gt; must &lt;strong&gt;start the text or follow whitespace&lt;/strong&gt;, which is what keeps
&lt;code&gt;https://…&lt;/code&gt; and &lt;code&gt;10:30&lt;/code&gt; from ever triggering;&lt;/li&gt;
&lt;li&gt;the run stops at the first non-shortcode character (&lt;code&gt;a–z&lt;/code&gt;, &lt;code&gt;0–9&lt;/code&gt;, &lt;code&gt;_&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;,
&lt;code&gt;-&lt;/code&gt;), so &lt;code&gt;:+1&lt;/code&gt;, &lt;code&gt;:-1&lt;/code&gt; and &lt;code&gt;:t-rex&lt;/code&gt; all survive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scanning backwards from the caret over ASCII-only characters has a nice side&lt;br&gt;
effect: the scan can never land in the middle of a surrogate pair, so multi-unit&lt;br&gt;
emoji in the text around the caret can't produce a bad slice.&lt;/p&gt;
&lt;h3&gt;
  
  
  Ranking so &lt;code&gt;:joy&lt;/code&gt; beats &lt;code&gt;:joy_cat&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A plain "everything that starts with the query" list buries the obvious answer.&lt;br&gt;
Ranking runs in three tiers, and each later tier only fills space the earlier&lt;br&gt;
ones left:&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;// 1. exact shortcode first  →  :joy gives 😂 before anything else&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;exact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shortcodes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;query&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;exact&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="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exact&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. then shortcode prefixes, shortest first&lt;/span&gt;
&lt;span class="c1"&gt;//    :smi → :smile before :smiley_cat&lt;/span&gt;
&lt;span class="n"&gt;prefixes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&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;byLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compareTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;byLength&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;byLength&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compareTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 3. finally gemoji tags, purely to widen recall&lt;/span&gt;
&lt;span class="c1"&gt;//    :happy, :workout — never ahead of a real shortcode&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That third tier is what makes &lt;code&gt;:happy&lt;/code&gt; find 😀 even though &lt;code&gt;happy&lt;/code&gt; isn't an&lt;br&gt;
alias — tags are discovery-only and never outrank a real shortcode. Emoji are&lt;br&gt;
de-duplicated across all three tiers, and a query longer than 32 characters is&lt;br&gt;
dropped so a runaway &lt;code&gt;:&lt;/code&gt;-run costs nothing.&lt;/p&gt;

&lt;p&gt;Accepting a suggestion replaces exactly the typed range with the glyph plus a&lt;br&gt;
trailing space, and puts the caret after it — the same manual-insert path the&lt;br&gt;
emoji picker uses, which is why setting the text can't re-trigger the strip and&lt;br&gt;
loop.&lt;/p&gt;
&lt;h2&gt;
  
  
  Private pins, next to conversation-wide pins
&lt;/h2&gt;

&lt;p&gt;GwinG already had one pin per conversation — the banner at the top of the&lt;br&gt;
chat that everyone sees. The new twist is a second, &lt;strong&gt;private&lt;/strong&gt; pin that only&lt;br&gt;
you see, synced across your own devices. The two coexist on the same message.&lt;/p&gt;
&lt;h3&gt;
  
  
  The data model
&lt;/h3&gt;

&lt;p&gt;Both live in one join table, distinguished by a nullable &lt;code&gt;user_id&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;conversation_id&lt;/code&gt;, &lt;code&gt;message_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;which message is pinned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pinned_by&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;who cast the pin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NULL&lt;/code&gt; = pin "for all"; a user id = that user's private pin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two &lt;strong&gt;partial unique indexes&lt;/strong&gt; keep it honest — one "for all" pin per message,&lt;br&gt;
and one private pin per user per message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- one shared pin, and one private pin per user, for a given message&lt;/span&gt;
&lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&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="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  One service, three delivery paths
&lt;/h3&gt;

&lt;p&gt;Pins can arrive over the WebSocket, over REST, or be torn down when the message&lt;br&gt;
itself is deleted. Rather than let three code paths re-implement the rules —&lt;br&gt;
membership, who may pin for everyone, whether the message is deleted — they all&lt;br&gt;
delegate to a single &lt;code&gt;pin_message&lt;/code&gt; / &lt;code&gt;unpin_message&lt;/code&gt; service. The rules themselves&lt;br&gt;
are small but they matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"For all"&lt;/strong&gt; in a group is admin/owner only, and announces itself with a
system row ("X pinned a message"). In a 1:1 chat both members may do it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"For me"&lt;/strong&gt; is open to &lt;em&gt;any&lt;/em&gt; member — even a non-admin in a group — and it
never emits a system row or notifies anyone. The event is echoed &lt;strong&gt;only to the
actor's own devices&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Unpinning one scope leaves the other intact, so the client tracks the two flags
separately (&lt;code&gt;pinnedForAll&lt;/code&gt; / &lt;code&gt;pinnedForMe&lt;/code&gt;) and OR-merges them on every update
instead of clobbering.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;scope&lt;/code&gt; field defaults to &lt;code&gt;"all"&lt;/code&gt;, so a client that predates the feature&lt;br&gt;
keeps working unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grouped bubbles: one burst, one block
&lt;/h2&gt;

&lt;p&gt;When the same person sends several messages in a row, they shouldn't look like&lt;br&gt;
several separate conversations. Sender grouping squares the corners that touch a&lt;br&gt;
neighbour and keeps the outer corners rounded, so a run reads as a single block.&lt;/p&gt;

&lt;p&gt;The subtlety is that we compute &lt;strong&gt;two different things&lt;/strong&gt; and they must not be&lt;br&gt;
conflated:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;gap/name group&lt;/strong&gt; — tight spacing and no repeated sender label;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;corner run&lt;/strong&gt; — which corners get squared.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The rectangle's sender-side corners follow the group position:&lt;/span&gt;
&lt;span class="c1"&gt;// first keeps its top, last keeps its bottom, a middle bubble squares both.&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;groupedMessageBubbleRadius&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="kd"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;isMe:&lt;/span&gt; &lt;span class="n"&gt;isMe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;isFirstInGroup:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;glueAbove&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;isLastInGroup:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;glueBelow&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 corner run breaks on a sender change, a day rollover, a system chip, or a&lt;br&gt;
&lt;strong&gt;photo-album grid&lt;/strong&gt; — the grid always keeps its own fully-rounded shape and&lt;br&gt;
never joins a run. Keeping the two notions separate is what lets a shortcode&lt;br&gt;
strip, a date separator and an album all sit in the run without producing&lt;br&gt;
mismatched seams.&lt;/p&gt;

&lt;h2&gt;
  
  
  The defects a green build didn't catch
&lt;/h2&gt;

&lt;p&gt;Before shipping the batch we run a behavioural review: read the diff end to end,&lt;br&gt;
trace each event and field across the client/server boundary, and hand-verify&lt;br&gt;
every candidate. The compiler and the unit tests were already happy, and it&lt;br&gt;
still found three real bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A pinned encrypted message previewed as ciphertext.&lt;/strong&gt; The &lt;code&gt;message_pinned&lt;/code&gt;&lt;br&gt;
broadcast was hand-built in two places — the WebSocket handler and the REST&lt;br&gt;
route — and both forgot the &lt;code&gt;is_encrypted&lt;/code&gt; field that the canonical serializer&lt;br&gt;
emits and the client reads. In an encrypted chat that meant the pin banner&lt;br&gt;
showed the raw ciphertext instead of "🔒 Encrypted message". The fix wasn't to&lt;br&gt;
add the field twice; it was to delete the duplication and build the payload in&lt;br&gt;
&lt;strong&gt;one shared function&lt;/strong&gt; so the two paths can't drift again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A half-glued bubble seam.&lt;/strong&gt; The grouping code decided "is this neighbour an&lt;br&gt;
album?" from &lt;code&gt;albumId != null&lt;/code&gt;, but an album is only &lt;em&gt;rendered&lt;/em&gt; as a grid when&lt;br&gt;
its run is longer than one message. A lone album row — its siblings outside the&lt;br&gt;
loaded window — painted as an ordinary bubble while the glue logic still refused&lt;br&gt;
to join it, so one side squared a corner and the neighbour stayed rounded: a&lt;br&gt;
visible notch. The fix makes the glue consult the &lt;strong&gt;rendered&lt;/strong&gt; album, not the raw&lt;br&gt;
field, with a test that fails against the old behaviour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A floating button covering the last row.&lt;/strong&gt; The list reserved bottom padding&lt;br&gt;
for its FAB stack sized against a &lt;strong&gt;40dp&lt;/strong&gt; mini button. But a mini floating&lt;br&gt;
action button lays out at &lt;strong&gt;48dp&lt;/strong&gt; on mobile — the 40dp circle sits inside&lt;br&gt;
Material's 48dp minimum tap target. On an admin account the extra 24dp pushed the&lt;br&gt;
topmost button over the last row's timestamp. The fix derives the clearance from&lt;br&gt;
the real box.&lt;/p&gt;

&lt;p&gt;None of these threw an exception. All three built, ran, passed CI, and did the&lt;br&gt;
wrong thing. That's the whole argument for reviewing behaviour, not just results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why write it up
&lt;/h2&gt;

&lt;p&gt;Each of these is small on its own. Together they're the difference between a chat&lt;br&gt;
app that works and one that feels considered — the shortcode that completes&lt;br&gt;
before you finish typing, the pin that's yours alone, the burst of messages that&lt;br&gt;
reads as one thought. We write them up as we ship them so the polish gets its&lt;br&gt;
share of the credit.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does emoji shortcode autocomplete work offline?&lt;/strong&gt;&lt;br&gt;
Yes. The shortcode table ships as a bundled asset and is loaded once, so matching&lt;br&gt;
never needs the network. If the asset ever fails to load, autocomplete simply&lt;br&gt;
stays inert rather than breaking the composer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can one message be pinned both privately and for everyone?&lt;/strong&gt;&lt;br&gt;
Yes. They're independent records in the same table, and the client keeps two&lt;br&gt;
flags per message so unpinning one leaves the other in place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who can pin a message for the whole group?&lt;/strong&gt;&lt;br&gt;
Admins and the owner. Any member — admin or not — can always pin privately for&lt;br&gt;
themselves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is offset handling safe with emoji in the text?&lt;/strong&gt;&lt;br&gt;
The token scanner only walks back over ASCII shortcode characters, so it stops at&lt;br&gt;
the first non-ASCII unit and can never split a surrogate pair. Caret offsets and&lt;br&gt;
&lt;code&gt;String.length&lt;/code&gt; are both in UTF-16 units in Flutter, so the replacement range is&lt;br&gt;
always valid.&lt;/p&gt;

&lt;p&gt;— the GwinG team&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>flutter</category>
      <category>showdev</category>
      <category>tongues</category>
      <category>languageexchange</category>
    </item>
    <item>
      <title>Building photo albums in Flutter</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:58:55 +0000</pubDate>
      <link>https://dev.to/frankmilvus/building-photo-albums-in-flutter-4op4</link>
      <guid>https://dev.to/frankmilvus/building-photo-albums-in-flutter-4op4</guid>
      <description>&lt;p&gt;Multi-select a handful of photos in &lt;strong&gt;GwinG&lt;/strong&gt; and they land as a single grid&lt;br&gt;
bubble — one caption, one timestamp, one tick, one notification — instead of a&lt;br&gt;
full-size bubble per image. That's the &lt;strong&gt;album bubble&lt;/strong&gt;, and it's the feature we&lt;br&gt;
shipped this week on top of a messenger that, until now, was built entirely around&lt;br&gt;
one image per message.&lt;/p&gt;

&lt;p&gt;The interesting part was never the grid. It was doing this &lt;strong&gt;without&lt;/strong&gt; tearing up&lt;br&gt;
the invariant the whole codebase leans on: &lt;em&gt;one message row = one media item&lt;/em&gt;. The&lt;br&gt;
rest of this post is how we layered Flutter photo albums on top of that rule&lt;br&gt;
instead of around it.&lt;/p&gt;

&lt;p&gt;GwinG is a language-exchange messenger — chat, calls and live translation,&lt;br&gt;
Flutter on the client, FastAPI and PostgreSQL behind it. If you're new here, start&lt;br&gt;
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;overview of GwinG&lt;/a&gt;.&lt;br&gt;
This post is one of a few we're writing today, alongside the&lt;br&gt;
&lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-shared-media-gallery-in-flutter.html" rel="noopener noreferrer"&gt;shared media gallery&lt;/a&gt;&lt;br&gt;
and the &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-flutter-linux-desktop-video-media-kit.html" rel="noopener noreferrer"&gt;Linux desktop video work&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  One row per media item — the rule we refused to break
&lt;/h2&gt;

&lt;p&gt;Here's the constraint, stated plainly. Our &lt;code&gt;messages&lt;/code&gt; table is one row per piece&lt;br&gt;
of media. That single fact is load-bearing across the app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delivery reconcile is URL-based.&lt;/strong&gt; When the upload pipeline finishes, the
client matches the optimistic bubble to the real row by the uploaded file's URL
— one file, one row, one match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read receipts and reactions are per row.&lt;/strong&gt; A thumb, a "read" tick, a count —
all column-level facts on a message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The offline outbox is one durable record per file.&lt;/strong&gt; Every attachment gets its
own record with its own progress and retry, so a killed app can resume each file
independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orphan cleanup scans &lt;code&gt;image_url&lt;/code&gt; per row.&lt;/strong&gt; A row that never got its URL is an
orphan; the sweep already knows how to find it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A naive album feature — "add an &lt;code&gt;attachments&lt;/code&gt; table" or "stuff a JSON array of&lt;br&gt;
URLs into one message" — breaks all four at once. Every read path (history,&lt;br&gt;
search, pins, previews) would need rewriting, and the URL-based reconcile would&lt;br&gt;
stop matching because one row would now carry N URLs.&lt;/p&gt;

&lt;p&gt;So we didn't do that. We kept one row per media item and layered a &lt;em&gt;group key&lt;/em&gt; on&lt;br&gt;
top of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Flutter photo albums need a group key
&lt;/h2&gt;

&lt;p&gt;Three nullable columns on &lt;code&gt;messages&lt;/code&gt; turn N independent rows into one logical&lt;br&gt;
album:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;album_id&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;    &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;album_index&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;album_size&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_messages_album_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;album_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://www.postgresql.org/docs/current/ddl-alter.html" rel="noopener noreferrer"&gt;additive-column&lt;/a&gt;&lt;br&gt;
shape matters: it's three plain columns and an index, not a new entity.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;album_id&lt;/code&gt;&lt;/strong&gt; (indexed &lt;code&gt;TEXT&lt;/code&gt;) is the shared group key. The &lt;em&gt;client&lt;/em&gt; generates
it at send time — a UUID-ish token — so an offline or still-uploading album is
already grouped before any server round-trip. Its length is bounded on the
server so a client can't stuff the indexed column with unbounded text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;album_index&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;INTEGER&lt;/code&gt;, 0-based) is the &lt;strong&gt;only ordering authority inside a
group&lt;/strong&gt;. This one matters more than it sounds. Sibling rows upload concurrently,
so their &lt;code&gt;created_at&lt;/code&gt; timestamps can arrive out of order. If any code ever sorted
a group by time, the grid would shuffle. It doesn't — it sorts by &lt;code&gt;album_index&lt;/code&gt;,
full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;album_size&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;INTEGER&lt;/code&gt;) is the total tile count, duplicated onto every
sibling on purpose. Denormalizing it means the &lt;code&gt;+N&lt;/code&gt; overlay and the notification
label render &lt;em&gt;before&lt;/em&gt; the last sibling has uploaded, without a &lt;code&gt;COUNT(*)&lt;/code&gt; on the
hot path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the columns are nullable and additive, the migration is safe to deploy&lt;br&gt;
ahead of the client — and the part we care about most is that &lt;strong&gt;&lt;code&gt;album_id IS NULL&lt;/code&gt;&lt;br&gt;
renders exactly like before&lt;/strong&gt;. Old rows, old clients, no regressions.&lt;/p&gt;
&lt;h3&gt;
  
  
  The anchor row
&lt;/h3&gt;

&lt;p&gt;An album needs &lt;em&gt;somewhere&lt;/em&gt; to hang the things that only make sense once per group.&lt;br&gt;
We call that row the &lt;strong&gt;anchor&lt;/strong&gt;, and it's simply the sibling with the lowest index&lt;br&gt;
(&lt;code&gt;album_index == 0&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it carries the &lt;strong&gt;caption&lt;/strong&gt; (every other sibling stores an empty &lt;code&gt;content&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;it carries the &lt;strong&gt;reply&lt;/strong&gt; target, and therefore the quote preview and the
"forwarded" flag;&lt;/li&gt;
&lt;li&gt;it owns the &lt;strong&gt;footer surface&lt;/strong&gt; — time, ticks, read count — and the &lt;strong&gt;reaction
surface&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;it's the row that fires the &lt;strong&gt;notification&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Siblings are collapsed: no caption, no reply, no footer, no separate push. The&lt;br&gt;
server enforces the shape on send — &lt;code&gt;album_size&lt;/code&gt; within range,&lt;br&gt;
&lt;code&gt;0 &amp;lt;= album_index &amp;lt; album_size&lt;/code&gt;, media present, and &lt;code&gt;album_id&lt;/code&gt; rejected on a row&lt;br&gt;
with no image — so a client can't post a half-formed group.&lt;/p&gt;
&lt;h2&gt;
  
  
  Grouping Flutter photo albums on the client
&lt;/h2&gt;

&lt;p&gt;Grouping happens on the reader's device, in the same pass that builds the message&lt;br&gt;
list's display items. The rule is deliberately narrow: &lt;strong&gt;consecutive&lt;/strong&gt; rows that&lt;br&gt;
share an &lt;code&gt;album_id&lt;/code&gt;, from the &lt;strong&gt;same sender&lt;/strong&gt;, with no date separator or system&lt;br&gt;
chip between them, collapse into one album item. Break the run — a different&lt;br&gt;
sender replies, a day boundary lands — and you get two albums, which is correct.&lt;/p&gt;

&lt;p&gt;Inside the item, the tiles are ordered by &lt;code&gt;album_index&lt;/code&gt; and never by time:&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;siblings&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;albumIndex&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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compareTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;albumIndex&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole trick. There's no album table, no join, no second read path. The&lt;br&gt;
list already built display groups for date separators and sender-name bands; an&lt;br&gt;
album is just one more kind of group, and it counts as a &lt;strong&gt;single visual unit&lt;/strong&gt;&lt;br&gt;
for the gap logic between messages.&lt;/p&gt;
&lt;h2&gt;
  
  
  A grid that's sized on the first frame
&lt;/h2&gt;

&lt;p&gt;The premium feel of an album lives or dies on one thing: &lt;strong&gt;the grid must be&lt;br&gt;
exactly the right size before the images load.&lt;/strong&gt; Get that wrong and the whole&lt;br&gt;
conversation jumps as tiles pop in.&lt;/p&gt;

&lt;p&gt;We avoid the jump by sizing from data we already store. Every image row keeps its&lt;br&gt;
&lt;code&gt;image_width&lt;/code&gt; and &lt;code&gt;image_height&lt;/code&gt; from the moment of upload, and the album block's&lt;br&gt;
aspect ratio comes from the anchor's own dimensions (clamped so an extreme&lt;br&gt;
panorama can't produce a degenerate grid). Each tile uses &lt;code&gt;BoxFit.cover&lt;/code&gt;, so it&lt;br&gt;
fills its cell whatever its native ratio — the same&lt;br&gt;
&lt;a href="https://docs.flutter.dev/ui/layout" rel="noopener noreferrer"&gt;layout model&lt;/a&gt; Flutter applies everywhere&lt;br&gt;
else. Sender and recipient both get the final layout on frame one; the images&lt;br&gt;
just fill in.&lt;/p&gt;

&lt;p&gt;The layout is nothing more than a lookup from tile count:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Layout&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;single full bubble — the existing path, unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;two equal columns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;one large tile left, two stacked right&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2×2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;two on top, three on the bottom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;3×2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7–9&lt;/td&gt;
&lt;td&gt;three columns; the last row is left-aligned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10+&lt;/td&gt;
&lt;td&gt;capped at ten, with a frosted &lt;code&gt;+N&lt;/code&gt; pill over the last tile&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two small details make it feel finished. Tiles &lt;strong&gt;cascade&lt;/strong&gt; in with a short&lt;br&gt;
per-index fade-and-slide — and that stagger is gated on the app's motion setting,&lt;br&gt;
so it's simply zero when the user has reduced motion on. And the whole grid sits&lt;br&gt;
inside one shared media border, so only the &lt;em&gt;outer&lt;/em&gt; corners are rounded and&lt;br&gt;
accent-bordered; the interior seams are a hairline 2 px.&lt;/p&gt;

&lt;p&gt;Open one tile and you land in the swipe-through gallery at exactly that photo.&lt;br&gt;
That gallery is its own story — we wrote it up in the&lt;br&gt;
&lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-shared-media-gallery-in-flutter.html" rel="noopener noreferrer"&gt;shared media gallery post&lt;/a&gt;&lt;br&gt;
— and albums just hand it an entry point and a starting tile.&lt;/p&gt;
&lt;h2&gt;
  
  
  One notification, one preview, one progress bar
&lt;/h2&gt;

&lt;p&gt;An album is one &lt;em&gt;logical&lt;/em&gt; message, so it should behave like one to the person&lt;br&gt;
receiving it — and this is where the fan-out bugs live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notifications.&lt;/strong&gt; Exactly one push per album, fired from the anchor. A ten-photo&lt;br&gt;
album must not buzz ten times. The broadcast layer treats a sibling (any row with&lt;br&gt;
&lt;code&gt;album_index &amp;gt; 0&lt;/code&gt;) as suppressed: it still emits the message event so the tile&lt;br&gt;
renders and gets its delivered tick, but it skips the push and the&lt;br&gt;
conversation-list preview entirely. The body is the same everywhere — &lt;code&gt;📷 N photos&lt;/code&gt;&lt;br&gt;
— whether it's the push, the in-app notification list, or the friend-list preview.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uploading.&lt;/strong&gt; The send path is still per-file, which is what gives us per-tile&lt;br&gt;
progress, retry and cancel for free. For an album we generate one &lt;code&gt;album_id&lt;/code&gt;, then&lt;br&gt;
loop the staged files, putting the caption and reply on the anchor only:&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;albumId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;newAlbumId&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;albumSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;staged&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="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;albumSize&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;isAnchor&lt;/span&gt; &lt;span class="o"&gt;=&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;beginPendingUpload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;filePath:&lt;/span&gt; &lt;span class="n"&gt;staged&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="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;kind:&lt;/span&gt; &lt;span class="n"&gt;UploadKind&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="n"&gt;isAnchor&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// caption lives on the anchor&lt;/span&gt;
    &lt;span class="nl"&gt;replyToId:&lt;/span&gt; &lt;span class="n"&gt;isAnchor&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;replyId&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="c1"&gt;// so does the reply&lt;/span&gt;
    &lt;span class="nl"&gt;albumId:&lt;/span&gt; &lt;span class="n"&gt;albumId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;albumIndex:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;albumSize:&lt;/span&gt; &lt;span class="n"&gt;albumSize&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;On screen that becomes &lt;strong&gt;one&lt;/strong&gt; thin aggregate bar across the bottom of the grid,&lt;br&gt;
plus a small spinner on each tile still in flight. A tile whose upload fails gets&lt;br&gt;
its own retry badge instead of spinning forever. Each tile is its own durable&lt;br&gt;
outbox record — the same "this must survive a session ending" instinct behind our&lt;br&gt;
&lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-linux-keyring-session-durability.html" rel="noopener noreferrer"&gt;Linux keyring session work&lt;/a&gt;&lt;br&gt;
— so killing the app mid-send restores the album, grouped, and resumes the tiles&lt;br&gt;
that hadn't landed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forward keeps the group; delete takes it apart
&lt;/h2&gt;

&lt;p&gt;Two actions are worth calling out because albums change their meaning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forward&lt;/strong&gt; copies &lt;code&gt;album_id&lt;/code&gt;, &lt;code&gt;album_index&lt;/code&gt; and &lt;code&gt;album_size&lt;/code&gt; onto every copy, so&lt;br&gt;
the album &lt;strong&gt;re-groups in the destination&lt;/strong&gt;. Forward ten photos and the other&lt;br&gt;
conversation gets an album, not ten loose images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delete&lt;/strong&gt; on an album removes &lt;strong&gt;all&lt;/strong&gt; siblings. And there's a tidy edge case on&lt;br&gt;
the other side: if an album ever drops to a single surviving row — siblings&lt;br&gt;
deleted one by one, say — that row clears its &lt;code&gt;album_id&lt;/code&gt; and &lt;strong&gt;degrades back to a&lt;br&gt;
plain single image&lt;/strong&gt;. The group key is optional, so the model simply falls back to&lt;br&gt;
the behaviour it had before albums existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd do next
&lt;/h2&gt;

&lt;p&gt;This phase is deliberately &lt;strong&gt;images-only&lt;/strong&gt;. The model doesn't need to change to&lt;br&gt;
add video tiles — &lt;code&gt;album_index&lt;/code&gt; is media-agnostic — so mixed photo-and-video&lt;br&gt;
albums are a clean later step: a per-tile player, a play badge, and the chunked&lt;br&gt;
upload path video already has. The&lt;br&gt;
&lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-flutter-linux-desktop-video-media-kit.html" rel="noopener noreferrer"&gt;Linux desktop video pipeline&lt;/a&gt;&lt;br&gt;
is a good look at the machinery that would slot in. Encrypted chats stay as they&lt;br&gt;
are: media is already blocked there, so albums inherit the same guard and the same&lt;br&gt;
friendly message.&lt;/p&gt;

&lt;p&gt;The takeaway, if there is one: a big feature doesn't have to mean a big schema. We&lt;br&gt;
added three nullable columns, one convention (the anchor), and a client-side&lt;br&gt;
grouping rule — and kept the invariants that make delivery, receipts, reactions&lt;br&gt;
and cleanup work. The grid is the visible part. The three columns are the whole&lt;br&gt;
trick.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do albums need a separate API endpoint?&lt;/strong&gt;&lt;br&gt;
No. An album is just a normal send with &lt;code&gt;album_id&lt;/code&gt;, &lt;code&gt;album_index&lt;/code&gt; and &lt;code&gt;album_size&lt;/code&gt;&lt;br&gt;
attached to each row. The same REST and WebSocket send paths handle it, and the&lt;br&gt;
same broadcast fans it out. There's nothing album-specific to route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How are album tiles ordered if the uploads finish out of order?&lt;/strong&gt;&lt;br&gt;
By &lt;code&gt;album_index&lt;/code&gt;, always. Sibling rows upload concurrently and can land with&lt;br&gt;
timestamps in any order, which is exactly why we forbid &lt;code&gt;created_at&lt;/code&gt; from ordering&lt;br&gt;
a group. The index is the only authority.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if an album loses a sibling?&lt;/strong&gt;&lt;br&gt;
The group stays a group while any sibling remains — a failed tile shows a retry&lt;br&gt;
badge rather than a hole. If the album drops all the way to one row, that row&lt;br&gt;
clears its &lt;code&gt;album_id&lt;/code&gt; and renders as a normal single image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work in encrypted chats?&lt;/strong&gt;&lt;br&gt;
Not yet. Media is already disabled in encrypted chats, so albums inherit the same&lt;br&gt;
restriction and the same user-facing message. When photo and video sending arrives&lt;br&gt;
there, albums will come along for free.&lt;/p&gt;

&lt;p&gt;— the GwinG team&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>flutter</category>
      <category>dart</category>
      <category>ux</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Staying signed in on Linux when the keyring doesn't survive a reboot</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:45:43 +0000</pubDate>
      <link>https://dev.to/frankmilvus/staying-signed-in-on-linux-when-the-keyring-doesnt-survive-a-reboot-536a</link>
      <guid>https://dev.to/frankmilvus/staying-signed-in-on-linux-when-the-keyring-doesnt-survive-a-reboot-536a</guid>
      <description>&lt;p&gt;We keep people signed in on desktop the same way we do on mobile: the session is&lt;br&gt;
an access/refresh token pair, and we would much rather store it in the operating&lt;br&gt;
system's secure store than in a plaintext file. On Linux that secure store is the&lt;br&gt;
keyring — libsecret talking to the Secret Service that &lt;code&gt;gnome-keyring&lt;/code&gt;&lt;br&gt;
implements. Getting &lt;strong&gt;Flutter Linux secure storage&lt;/strong&gt; to behave on every machine&lt;br&gt;
turned out to be harder than the plugin's tidy &lt;code&gt;write&lt;/code&gt;/&lt;code&gt;read&lt;/code&gt; API suggests. The&lt;br&gt;
last bug we fixed was the most interesting one: a keyring that kept its promise&lt;br&gt;
for exactly as long as the machine stayed switched on.&lt;/p&gt;

&lt;p&gt;This is the story of that bug, why the "obvious" proofs that a keyring works were&lt;br&gt;
all wrong, and the small piece of Linux kernel state that finally made the check&lt;br&gt;
honest.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup: Flutter Linux secure storage and a proof-gated fallback
&lt;/h2&gt;

&lt;p&gt;On desktop, GwinG persists the session through &lt;code&gt;flutter_secure_storage&lt;/code&gt;. On&lt;br&gt;
Android and iOS that is the OS keystore, which is always present. On desktop it&lt;br&gt;
is the OS &lt;em&gt;keyring&lt;/em&gt; — libsecret / &lt;code&gt;gnome-keyring&lt;/code&gt; on Linux, Credential Manager on&lt;br&gt;
Windows — and a keyring is a much less certain thing. It can be missing, locked,&lt;br&gt;
or reachable only from inside a graphical session.&lt;/p&gt;

&lt;p&gt;The plugin makes this worse in a quiet way:&lt;br&gt;
&lt;a href="https://pub.dev/packages/flutter_secure_storage" rel="noopener noreferrer"&gt;&lt;code&gt;flutter_secure_storage&lt;/code&gt;&lt;/a&gt; can&lt;br&gt;
accept a write and keep nothing. No exception, no error — the call returns&lt;br&gt;
normally and the value is simply not there later. A store backed by a transient&lt;br&gt;
collection returns the value immediately and drops it when the process exits.&lt;/p&gt;

&lt;p&gt;So we do two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We write the tokens to the secure store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Until that store proves it is durable, we also keep a plaintext copy&lt;/strong&gt; in
&lt;code&gt;SharedPreferences&lt;/code&gt;, so the session is always restorable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The moment the store is proved durable, we delete the plaintext copy, and the&lt;br&gt;
keyring becomes the only place the session lives. That "proved durable" step is&lt;br&gt;
the whole game, and it is where both of this year's desktop session bugs came&lt;br&gt;
from.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fallback is proof-gated, not permanent
&lt;/h2&gt;

&lt;p&gt;A plaintext fallback that never goes away would be the simplest, safest thing for&lt;br&gt;
the session — and we rejected it. It would leave a refresh token unencrypted on&lt;br&gt;
every machine, including the many whose keyring works perfectly. That is the&lt;br&gt;
wrong default.&lt;/p&gt;

&lt;p&gt;Instead the fallback is &lt;strong&gt;proof-gated&lt;/strong&gt;. We trust the secure store only after we&lt;br&gt;
have evidence that it actually keeps values across launches, and until then we&lt;br&gt;
keep the encrypted copy alongside the plaintext one. The evidence is deliberately&lt;br&gt;
&lt;em&gt;not&lt;/em&gt; a same-process write→read round-trip, which as we said can succeed against&lt;br&gt;
a store that never persists. The proof is a value written on one launch and read&lt;br&gt;
back on the &lt;strong&gt;next&lt;/strong&gt; one:&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;// A value written now and compared on the next launch is the only proof that&lt;/span&gt;
&lt;span class="c1"&gt;// outlives the process that wrote it. A same-process round-trip proves nothing:&lt;/span&gt;
&lt;span class="c1"&gt;// a transient collection passes it and loses the value anyway.&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;proofMatches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the value we wrote last launch is still there this launch, the store kept&lt;br&gt;
something across a restart, and we can trust it. That was the design after our&lt;br&gt;
first desktop bug, and it fixed the "login screen on every launch" report. Then&lt;br&gt;
came Linux.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug: a keyring that only lives for the running session
&lt;/h2&gt;

&lt;p&gt;A user on &lt;strong&gt;niri/Arch&lt;/strong&gt; had &lt;code&gt;gnome-keyring&lt;/code&gt; installed and running — it owns&lt;br&gt;
&lt;code&gt;org.freedesktop.secrets&lt;/code&gt;, via&lt;br&gt;
&lt;a href="https://specifications.freedesktop.org/secret-service-spec/latest/" rel="noopener noreferrer"&gt;D-Bus activation&lt;/a&gt;&lt;br&gt;
— but its default collection was never unlocked at login, because niri starts the&lt;br&gt;
daemon outside any PAM unlock.&lt;/p&gt;

&lt;p&gt;The daemon accepted a write and returned it on the next read &lt;strong&gt;within the same&lt;br&gt;
session&lt;/strong&gt;. So our one-launch proof matched. The store looked durable. We deleted&lt;br&gt;
the plaintext fallback.&lt;/p&gt;

&lt;p&gt;But the value only ever lived inside the running daemon. After a reboot it was&lt;br&gt;
gone — and so was the session, because we had already thrown away the only copy&lt;br&gt;
that would have survived. The fallback had protected the user right up until the&lt;br&gt;
keyring "passed the test", and then stopped.&lt;/p&gt;

&lt;p&gt;The diagnostics on that machine told the story plainly: the app's keyring entry&lt;br&gt;
held an &lt;strong&gt;empty secret&lt;/strong&gt;, the real tokens sat in the plaintext file with the&lt;br&gt;
marker recording that the last durable save went to &lt;code&gt;prefs&lt;/code&gt;, and the proof value&lt;br&gt;
in prefs matched the proof value in the keyring. Everything agreed that the store&lt;br&gt;
was fine. The machine simply disagreed at the next boot.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the one-launch proof was wrong
&lt;/h2&gt;

&lt;p&gt;Our proof answered the question "did this value survive the process that wrote&lt;br&gt;
it?" That is the right question for a store that can drop data when an app exits.&lt;br&gt;
It is the wrong question on Linux, where a keyring can drop data when the&lt;br&gt;
&lt;em&gt;session&lt;/em&gt; exits — a different, later, more destructive event.&lt;/p&gt;

&lt;p&gt;An app restart and a reboot are not the same boundary. On a machine whose keyring&lt;br&gt;
caches writes in memory, an app restart inside one boot proves nothing about the&lt;br&gt;
reboot that follows it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: make the proof cross a boot
&lt;/h2&gt;

&lt;p&gt;The fix is small and, in hindsight, obvious: the proof record now carries the&lt;br&gt;
kernel &lt;strong&gt;boot id&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Linux exposes a fresh, random identifier for the running boot at&lt;br&gt;
&lt;code&gt;/proc/sys/kernel/random/boot_id&lt;/code&gt;. It changes on every boot. So we write the proof&lt;br&gt;
as &lt;code&gt;"&amp;lt;bootId&amp;gt;:&amp;lt;nonce&amp;gt;"&lt;/code&gt; — the boot the value was written in, plus a nonce that&lt;br&gt;
makes it unique — and we trust the store only when a proof written in one boot is&lt;br&gt;
read back &lt;strong&gt;in a later boot&lt;/strong&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;bootId&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;_linuxBootId&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// null off Linux, or unreadable&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;proofMatches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// `expected` is "&amp;lt;bootId&amp;gt;:&amp;lt;nonce&amp;gt;". A record with no separator is a legacy&lt;/span&gt;
&lt;span class="c1"&gt;// value, which can never prove a reboot was survived.&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;expectedBootId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_proofBootId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&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;crossedBoot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;bootId&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;expectedBootId&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;expectedBootId&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;bootId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;_keystoreTrusted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;proofMatches&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;isLinuxDesktop&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;crossedBoot&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A keyring that merely caches for the running session can never satisfy&lt;br&gt;
&lt;code&gt;crossedBoot&lt;/code&gt;: the proof it holds was written in the current boot, so the store&lt;br&gt;
never reaches the durable state. The plaintext fallback stays, and the session&lt;br&gt;
survives the reboot. A keyring that genuinely persists reaches durable exactly&lt;br&gt;
one reboot after sign-in, then becomes the sole copy, as designed.&lt;/p&gt;

&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Legacy records can never pass.&lt;/strong&gt; A proof written by the old build has no &lt;code&gt;:&lt;/code&gt;
separator, so &lt;code&gt;_proofBootId&lt;/code&gt; returns null and &lt;code&gt;crossedBoot&lt;/code&gt; is false. An
upgrade cannot falsely trust a store; the next launch simply replaces the
record with the boot-id format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The check is scoped to Linux.&lt;/strong&gt; Off Linux the platform keystore (Android/iOS
Keystore, Windows Credential Manager) is reliable, so the boot-id requirement
is disabled and a matching proof is enough — unchanged behaviour. We added
&lt;code&gt;isLinuxDesktop&lt;/code&gt; to &lt;code&gt;platform_support.dart&lt;/code&gt; precisely to keep this to the one
flaky platform, with &lt;code&gt;isDesktop&lt;/code&gt; still covering Windows and macOS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Failing safe: an unreadable boot id keeps you signed in
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;_linuxBootId()&lt;/code&gt; returns null if it cannot read the file, and a null boot id can&lt;br&gt;
never satisfy &lt;code&gt;crossedBoot&lt;/code&gt;. That means a read failure is treated as "not&lt;br&gt;
durable" — the conservative direction, the one that keeps the user signed in&lt;br&gt;
rather than out. We would rather leave a fallback in place than risk trusting a&lt;br&gt;
store we could not assess.&lt;/p&gt;

&lt;p&gt;The same principle runs through the whole check: every uncertain outcome resolves&lt;br&gt;
to keeping the session, and only positive, cross-boot evidence moves the app to&lt;br&gt;
keystore-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious alternatives don't work
&lt;/h2&gt;

&lt;p&gt;We considered the two shortcuts and rejected both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never drop the plaintext fallback on desktop.&lt;/strong&gt; Simplest, and it would have
avoided this bug — but it leaves the refresh token unencrypted on every healthy
machine, forever. We rejected it once before and kept it rejected; it trades a
real, permanent weakening of secret storage for a bug that has a precise fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Require N consecutive launches.&lt;/strong&gt; More launches do not help. A daemon that
keeps the value in memory passes any number of same-boot restarts; it only
fails at the boundary a reboot represents. N consecutive launches only ever
prove same-boot survival.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The boot boundary is the honest version of "durable across a restart": it is&lt;br&gt;
precise, it keeps the keystore-only path for healthy machines, and it costs those&lt;br&gt;
machines exactly one extra launch carrying a plaintext copy.&lt;/p&gt;

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

&lt;p&gt;Not much, which is the point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On a machine whose keyring never proves durable, the fallback stays and &lt;strong&gt;the
session survives reboots&lt;/strong&gt;. The user stays signed in, at the cost of an
unencrypted local copy — which they can remove by unlocking the keyring's
default collection at login.&lt;/li&gt;
&lt;li&gt;On a healthy keyring, the plaintext copy is kept for one launch cycle after
sign-in, then dropped; after that the keyring is the only copy, as before.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The change is covered by a test that launches the app twice inside one boot (the&lt;br&gt;
fallback is kept) and once in a later boot (the store is trusted and the fallback&lt;br&gt;
dropped). The rest of the storage suite still passes, so the Linux path stayed&lt;br&gt;
additive.&lt;/p&gt;

&lt;p&gt;If you want the surrounding work, we shipped a&lt;br&gt;
&lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-photo-albums-in-flutter.html" rel="noopener noreferrer"&gt;photo album flow&lt;/a&gt;,&lt;br&gt;
a &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-shared-media-gallery-in-flutter.html" rel="noopener noreferrer"&gt;shared media gallery&lt;/a&gt;&lt;br&gt;
and the &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-flutter-linux-desktop-video-media-kit.html" rel="noopener noreferrer"&gt;Linux desktop video and media kit&lt;/a&gt;&lt;br&gt;
the same day, and there's a&lt;br&gt;
&lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-11-what-is-tongues-language-exchange-messenger.html" rel="noopener noreferrer"&gt;full overview of GwinG&lt;/a&gt;&lt;br&gt;
if you're new here.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does this log me out on a healthy Linux keyring?&lt;/strong&gt;&lt;br&gt;
No. It keeps the plaintext copy for one launch cycle after you sign in, then drops&lt;br&gt;
it once a proof crosses a reboot. After that the keyring is the only copy, exactly&lt;br&gt;
as before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just always keep the plaintext fallback on Linux?&lt;/strong&gt;&lt;br&gt;
Because it would leave the refresh token unencrypted on every Linux machine,&lt;br&gt;
including the ones whose keyring is perfectly fine. Staying signed in matters, but&lt;br&gt;
not at the permanent cost of plaintext secrets everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the app can't read the boot id?&lt;/strong&gt;&lt;br&gt;
It treats the store as not durable and keeps the fallback. That is the safe&lt;br&gt;
direction — we would rather keep you signed in than trust a store we could not&lt;br&gt;
assess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this affect Android, iOS, Windows or macOS?&lt;/strong&gt;&lt;br&gt;
No. Off Linux the boot-id requirement is disabled and a proof that survives a&lt;br&gt;
restart is enough, because the platform keystore there is reliable. Legacy proof&lt;br&gt;
records behave the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the boot id sensitive?&lt;/strong&gt;&lt;br&gt;
No. It is a random per-boot identifier whose only use here is answering "is this a&lt;br&gt;
different boot?" It is not tied to a user or a machine, and it carries no data&lt;br&gt;
beyond the boot boundary itself.&lt;/p&gt;

&lt;p&gt;— the GwinG team&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>flutter</category>
      <category>linux</category>
      <category>security</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Playing video on Flutter Linux desktop with media_kit</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:45:00 +0000</pubDate>
      <link>https://dev.to/frankmilvus/playing-video-on-flutter-linux-desktop-with-mediakit-5b7o</link>
      <guid>https://dev.to/frankmilvus/playing-video-on-flutter-linux-desktop-with-mediakit-5b7o</guid>
      <description>&lt;p&gt;Video is the one media type in &lt;strong&gt;GwinG&lt;/strong&gt; that cannot fail quietly. A photo that&lt;br&gt;
won't load falls back to an icon tile and most people never notice; a video that&lt;br&gt;
won't play looks like the whole app is broken. So when every video bubble on our&lt;br&gt;
Linux desktop build started showing &lt;strong&gt;"Video expired"&lt;/strong&gt; — the label we reserve for&lt;br&gt;
a file the server no longer has — we knew something was off. Flutter Linux desktop&lt;br&gt;
video simply had no player behind it: &lt;code&gt;video_player&lt;/code&gt; ships no Linux implementation,&lt;br&gt;
and &lt;code&gt;media_kit&lt;/code&gt; was not wired up yet. This is the story of finding that out, and&lt;br&gt;
moving playback behind one abstraction so the chat widgets never have to know which&lt;br&gt;
player is underneath.&lt;/p&gt;

&lt;p&gt;It turned out to be less about adding a library and more about being honest with the&lt;br&gt;
UI: the app wasn't failing to &lt;em&gt;find&lt;/em&gt; the video, it was failing to &lt;em&gt;play&lt;/em&gt; it, and the&lt;br&gt;
error path told the user the wrong story.&lt;/p&gt;
&lt;h2&gt;
  
  
  The symptom: every video bubble said "Video expired"
&lt;/h2&gt;

&lt;p&gt;The report was suspiciously uniform. Not "some videos fail", not "big videos fail"&lt;br&gt;
— &lt;strong&gt;every&lt;/strong&gt; video, in every chat, on the Linux desktop build. That uniformity is&lt;br&gt;
usually the clue you want: a codec problem, a corrupt file or a flaky network all&lt;br&gt;
produce a mess of mixed results. An all-or-nothing failure means the problem is&lt;br&gt;
above the file, in the layer that is supposed to decode it.&lt;/p&gt;

&lt;p&gt;The other half of the clue was that the download was fine. Files downloaded&lt;br&gt;
completely, landed in the cache, were playable by any other tool on the machine —&lt;br&gt;
and the bubble still said &lt;em&gt;Video expired&lt;/em&gt;. The label was a lie the app told itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Flutter Linux desktop video had no backend at all
&lt;/h2&gt;

&lt;p&gt;Here is the root cause, and it is almost embarrassingly simple.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;video_player&lt;/code&gt; plugin ships platform implementations for Android, iOS, macOS&lt;br&gt;
and web — that is &lt;code&gt;video_player_android&lt;/code&gt;, &lt;code&gt;video_player_avfoundation&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;video_player_web&lt;/code&gt;. There is &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;video_player_linux&lt;/code&gt;. On Linux, calling&lt;br&gt;
&lt;code&gt;VideoPlayerController.initialize()&lt;/code&gt; does not fail because the file is missing or&lt;br&gt;
malformed; it throws &lt;code&gt;UnimplementedError&lt;/code&gt;, because the method channel has nobody on&lt;br&gt;
the other end.&lt;/p&gt;

&lt;p&gt;Our video widget's control flow made that throw look like an expiry:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The auto-download step runs first. It is pure Dart (&lt;code&gt;http&lt;/code&gt;), so it succeeds on
Linux exactly as it does everywhere else. The file is on disk.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;initialize()&lt;/code&gt; is then called on that local file and throws &lt;code&gt;UnimplementedError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The streaming fallback runs, and throws for the same reason.&lt;/li&gt;
&lt;li&gt;The outer &lt;code&gt;catch&lt;/code&gt; sets &lt;code&gt;_errored = true&lt;/code&gt;, and the error branch renders our
fallback tile — the one labelled &lt;strong&gt;"Video expired"&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing expired. The file was there. The player did not exist. We had a UI that&lt;br&gt;
described a server-side problem while the real problem was that we had never given&lt;br&gt;
Linux a decoder.&lt;/p&gt;
&lt;h2&gt;
  
  
  One interface, two players
&lt;/h2&gt;

&lt;p&gt;We could have fixed this by special-casing Linux all over the widget. We didn't&lt;br&gt;
want that, and we didn't want the reverse either — dragging &lt;code&gt;media_kit&lt;/code&gt; into&lt;br&gt;
Android, iOS, macOS and web where &lt;code&gt;video_player&lt;/code&gt; already works perfectly. The two&lt;br&gt;
chat widgets (&lt;code&gt;video_player.dart&lt;/code&gt; and its fullscreen sibling) were written against&lt;br&gt;
&lt;code&gt;VideoPlayerController&lt;/code&gt;, and we wanted that to stay true.&lt;/p&gt;

&lt;p&gt;So we put a seam between "the widget" and "the player": a single&lt;br&gt;
&lt;code&gt;VideoPlaybackController&lt;/code&gt; that both backends implement. It extends &lt;code&gt;ValueNotifier&lt;/code&gt;&lt;br&gt;
and mirrors the &lt;code&gt;video_player&lt;/code&gt; API the call sites already used, which is what made&lt;br&gt;
the migration mechanical rather than a rewrite:&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;/// A video surface and its transport, behind a single API.&lt;/span&gt;
&lt;span class="c1"&gt;///&lt;/span&gt;
&lt;span class="c1"&gt;/// Extends [ValueNotifier] so it is listened to and read exactly like a&lt;/span&gt;
&lt;span class="c1"&gt;/// `VideoPlayerController` — the call sites were written against that type.&lt;/span&gt;
&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VideoPlaybackController&lt;/span&gt;
    &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;ValueNotifier&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;VideoPlaybackValue&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;Future&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;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="n"&gt;Future&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;play&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;Future&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;pause&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;Future&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;seekTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;Future&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;setPlaybackSpeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;/// The video surface to place in the widget tree.&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;buildView&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 pick between implementations is a one-line, platform-checked function, so&lt;br&gt;
nothing downstream branches on the platform at all:&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;/// Whether video playback on this platform runs through `media_kit` (libmpv)&lt;/span&gt;
&lt;span class="c1"&gt;/// instead of the `video_player` plugin.&lt;/span&gt;
&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;videoUsesMediaKit&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;isLinuxDesktop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;VideoPlaybackController&lt;/span&gt; &lt;span class="nf"&gt;createVideoPlaybackController&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kt"&gt;Uri&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;networkUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;videoUsesMediaKit&lt;/span&gt;
      &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;MediaKitVideoController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;networkUrl:&lt;/span&gt; &lt;span class="n"&gt;networkUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;filePath:&lt;/span&gt; &lt;span class="n"&gt;filePath&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;PluginVideoController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nl"&gt;networkUrl:&lt;/span&gt; &lt;span class="n"&gt;networkUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nl"&gt;filePath:&lt;/span&gt; &lt;span class="n"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two thin wrappers sit behind it. &lt;code&gt;PluginVideoController&lt;/code&gt; is a pass-through to the&lt;br&gt;
&lt;code&gt;video_player&lt;/code&gt; plugin and is used on Android, iOS, macOS and web — unchanged.&lt;br&gt;
&lt;code&gt;MediaKitVideoController&lt;/code&gt; owns a media_kit &lt;code&gt;Player&lt;/code&gt; plus a &lt;code&gt;VideoController&lt;/code&gt;, and&lt;br&gt;
maps &lt;code&gt;player.stream.*&lt;/code&gt; (playing, position, duration, video params) onto our small&lt;br&gt;
&lt;code&gt;VideoPlaybackValue&lt;/code&gt; snapshot. It renders the surface with&lt;br&gt;
&lt;code&gt;Video(controls: NoVideoControls)&lt;/code&gt; because the app draws its own controls; letting&lt;br&gt;
media_kit draw a second set would have produced two overlapping transports.&lt;/p&gt;

&lt;p&gt;The widgets now call &lt;code&gt;createVideoPlaybackController(...)&lt;/code&gt;, render&lt;br&gt;
&lt;code&gt;controller.buildView()&lt;/code&gt; instead of &lt;code&gt;VideoPlayer(...)&lt;/code&gt;, and are otherwise identical&lt;br&gt;
to before. &lt;code&gt;MediaKit.ensureInitialized()&lt;/code&gt; runs once at startup, and only on the&lt;br&gt;
platform that needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  media_kit, without dragging every platform along
&lt;/h2&gt;

&lt;p&gt;The obvious way to add media_kit is its aggregate package,&lt;br&gt;
&lt;code&gt;media_kit_libs_video&lt;/code&gt;, which bundles the native libraries for all supported&lt;br&gt;
platforms. That is exactly wrong for us: it would pull native video libs into the&lt;br&gt;
Android, iOS, macOS and web builds that already use &lt;code&gt;video_player&lt;/code&gt; and are already&lt;br&gt;
working. We chose the platform-scoped &lt;code&gt;media_kit_libs_linux&lt;/code&gt; instead, so only Linux&lt;br&gt;
gets the extra native payload.&lt;/p&gt;

&lt;p&gt;One honest caveat we noted in the changelog: &lt;code&gt;media_kit_video&lt;/code&gt; itself declares&lt;br&gt;
native plugins for every platform, so it is still compiled into the non-Linux&lt;br&gt;
builds even though only Linux uses it. That is a small size cost we accepted for&lt;br&gt;
the convenience of a single pubspec dependency. If it ever matters, the clean fix&lt;br&gt;
is to move the media_kit implementation behind a conditional import; for now the&lt;br&gt;
cost is not worth the extra moving part.&lt;/p&gt;

&lt;p&gt;If you're looking for the package, the &lt;a href="https://pub.dev/packages/media_kit" rel="noopener noreferrer"&gt;media_kit on pub.dev&lt;/a&gt;&lt;br&gt;
docs are unusually good, and Flutter's &lt;a href="https://docs.flutter.dev/platform-integration/desktop" rel="noopener noreferrer"&gt;desktop support page&lt;/a&gt;&lt;br&gt;
is worth a read before you assume a plugin works on every target.&lt;/p&gt;

&lt;h2&gt;
  
  
  The packaging reality: libmpv and libepoxy
&lt;/h2&gt;

&lt;p&gt;Adding a player is not just a Dart dependency — on Linux, &lt;code&gt;media_kit&lt;/code&gt; renders&lt;br&gt;
through the system &lt;strong&gt;libmpv&lt;/strong&gt; and OpenGL via &lt;strong&gt;libepoxy&lt;/strong&gt;. That makes them hard&lt;br&gt;
runtime requirements of the shipped app, and there are two ways to ship on Linux:&lt;br&gt;
a &lt;code&gt;.deb&lt;/code&gt; and a portable tarball. Both had to learn about the new libraries.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.deb&lt;/code&gt; declares its runtime dependencies in &lt;code&gt;Depends&lt;/code&gt;, and a missing entry does&lt;br&gt;
not fail the build — it makes the installed app die at startup with a cryptic&lt;br&gt;
"error while loading shared libraries". So the dependency list now includes the mpv&lt;br&gt;
and epoxy packages, derived the same way as every other entry: by reading what the&lt;br&gt;
binary actually links against. The CI workflow installs the &lt;code&gt;-dev&lt;/code&gt; packages to build&lt;br&gt;
and then verifies the &lt;code&gt;mpv&lt;/code&gt; and &lt;code&gt;epoxy&lt;/code&gt; &lt;code&gt;pkg-config&lt;/code&gt; modules are present, so a build&lt;br&gt;
machine that can't satisfy them fails early instead of shipping a bundle that can't&lt;br&gt;
start.&lt;/p&gt;

&lt;p&gt;The portable install path can't rely on a package manager to resolve anything, so&lt;br&gt;
when its post-install &lt;code&gt;ldd&lt;/code&gt; check finds an unresolved library it prints a&lt;br&gt;
distribution-specific hint naming the mpv/libepoxy packages per distro. That is the&lt;br&gt;
same fail-soft posture the rest of our desktop support takes, and it's covered&lt;br&gt;
alongside Google sign-in and session persistence in our desktop support notes if you&lt;br&gt;
want the wider picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could verify, and what we couldn't
&lt;/h2&gt;

&lt;p&gt;We are careful to say what is proven and what is assumed. On the Linux side we&lt;br&gt;
verified: the release build succeeds; &lt;code&gt;media_kit_video&lt;/code&gt; and &lt;code&gt;media_kit_libs_linux&lt;/code&gt;&lt;br&gt;
show up in the generated plugin registrant; the binary links &lt;code&gt;libmpv&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;libepoxy&lt;/code&gt;; the bundle launches, prints the Linux libs as registered, restores a&lt;br&gt;
session and connects; and &lt;code&gt;flutter analyze&lt;/code&gt; is clean on the changed files.&lt;/p&gt;

&lt;p&gt;What we could &lt;strong&gt;not&lt;/strong&gt; verify in CI is the thing users actually care about — decoding&lt;br&gt;
a real video on Linux, because that needs a real video message and a live session on&lt;br&gt;
a real desktop. The build, the linking, the plugin registration and the startup path&lt;br&gt;
are verified; the decode path is not, in automation. We say that plainly rather than&lt;br&gt;
claiming a fix we haven't watched play.&lt;/p&gt;

&lt;p&gt;The existing tests stayed green, which is a small reassurance that the abstraction&lt;br&gt;
didn't change behaviour on the platforms it wasn't meant to touch: on the test&lt;br&gt;
platform &lt;code&gt;videoUsesMediaKit&lt;/code&gt; is false, so the fullscreen widget's close test still&lt;br&gt;
exercises the stubbed &lt;code&gt;video_player&lt;/code&gt; path — which throws, exactly as it did before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits we are keeping on purpose
&lt;/h2&gt;

&lt;p&gt;Two honest gaps, because a post that only lists wins is not useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows still routes through &lt;code&gt;video_player&lt;/code&gt;.&lt;/strong&gt; There is no Windows
implementation either, so videos there are broken in the same way — and untested,
because Windows has never been run on real hardware. The follow-up is to drop
Windows into the same &lt;code&gt;media_kit&lt;/code&gt; branch (plus its Windows libs) once we can
actually test it. We deliberately didn't bolt it on blind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thumbnails and clip compression have no Linux backend.&lt;/strong&gt; The &lt;code&gt;video_thumbnail&lt;/code&gt;
and &lt;code&gt;v_video_compressor&lt;/code&gt; plugins don't cover Linux, so first-frame extraction and
pre-upload compression stay best-effort and fail soft: the bubble simply keeps its
icon tile. That's the correct degradation, and it's unchanged by this work.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Why did the video bubble say "Video expired" when the file was fine?&lt;/strong&gt;&lt;br&gt;
Because the auto-download succeeded and the &lt;em&gt;player&lt;/em&gt; was missing. &lt;code&gt;initialize()&lt;/code&gt;&lt;br&gt;
threw &lt;code&gt;UnimplementedError&lt;/code&gt; on Linux, the widget's outer &lt;code&gt;catch&lt;/code&gt; treated any&lt;br&gt;
initialization failure as "unavailable", and its error branch is the tile labelled&lt;br&gt;
"Video expired" — a label written for a server-side expiry, reused for a client-side&lt;br&gt;
gap. The fix in the UI is that Linux now has a real player; the lesson is that error&lt;br&gt;
copy should describe what actually failed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why media_kit and not a &lt;code&gt;video_player&lt;/code&gt; Linux package?&lt;/strong&gt;&lt;br&gt;
There isn't one to install — &lt;code&gt;video_player&lt;/code&gt; is implemented for Android, iOS, macOS&lt;br&gt;
and web only, and the plugin's contract is that unsupported platforms throw. media_kit&lt;br&gt;
is the pragmatic Linux backend because it renders through libmpv, which the distros&lt;br&gt;
already package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do the chat widgets know they're on Linux now?&lt;/strong&gt;&lt;br&gt;
No, and that's the point. They call &lt;code&gt;createVideoPlaybackController(...)&lt;/code&gt; and render&lt;br&gt;
&lt;code&gt;controller.buildView()&lt;/code&gt;. The only place that branches on the platform is&lt;br&gt;
&lt;code&gt;videoUsesMediaKit&lt;/code&gt;, and the only place that initializes the backend is a single&lt;br&gt;
startup call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does adding media_kit bloat the mobile builds?&lt;/strong&gt;&lt;br&gt;
A little. We avoided the aggregate libs package so the native video libraries land&lt;br&gt;
only on Linux, but &lt;code&gt;media_kit_video&lt;/code&gt; still compiles its plugin scaffolding into the&lt;br&gt;
other platforms. It's a small, accepted cost; a conditional import is the escape&lt;br&gt;
hatch if it ever grows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will Windows work now too?&lt;/strong&gt;&lt;br&gt;
Not yet. Windows has no &lt;code&gt;video_player&lt;/code&gt; implementation and has never been tested on&lt;br&gt;
real hardware, so we left it on the broken-but-known path and listed moving it onto&lt;br&gt;
media_kit as the follow-up rather than shipping an untested change.&lt;/p&gt;

&lt;p&gt;If you want the shape of the desktop effort around this, we wrote up&lt;br&gt;
&lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-shared-media-gallery-in-flutter.html" rel="noopener noreferrer"&gt;shared media and albums&lt;/a&gt;&lt;br&gt;
and&lt;br&gt;
&lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-photo-albums-in-flutter.html" rel="noopener noreferrer"&gt;photo albums&lt;/a&gt;&lt;br&gt;
the same day — and the other half of running a desktop app is keeping people signed&lt;br&gt;
in, which is its own story in&lt;br&gt;
&lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-linux-keyring-session-durability.html" rel="noopener noreferrer"&gt;making the Linux session survive a reboot&lt;/a&gt;.&lt;br&gt;
If you're new here, start with&lt;br&gt;
&lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-11-what-is-tongues-language-exchange-messenger.html" rel="noopener noreferrer"&gt;what GwinG is&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;— the GwinG team&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>flutter</category>
      <category>linux</category>
      <category>desktop</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A shared media gallery for chats and profiles</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:44:13 +0000</pubDate>
      <link>https://dev.to/frankmilvus/a-shared-media-gallery-for-chats-and-profiles-24po</link>
      <guid>https://dev.to/frankmilvus/a-shared-media-gallery-for-chats-and-profiles-24po</guid>
      <description>&lt;p&gt;Open a friend's profile — or a group's info screen — and you can now browse every&lt;br&gt;
photo, video, document and link the two participants have exchanged. We call it the&lt;br&gt;
&lt;strong&gt;shared media gallery&lt;/strong&gt;, a familiar idea from other messengers, scoped tightly to&lt;br&gt;
the conversation between you.&lt;/p&gt;

&lt;p&gt;The important word there is &lt;em&gt;conversation-scoped&lt;/em&gt;. The gallery is not a cross-chat&lt;br&gt;
index of everything a user ever sent; it's the media belonging to one thread. On a&lt;br&gt;
profile that thread is the 1:1 chat between you and the person; in group info it's&lt;br&gt;
the group. One read serves both shapes, and the profile reaches it through a&lt;br&gt;
&lt;strong&gt;read-only peer resolver&lt;/strong&gt; that never creates a conversation as a side effect. It&lt;br&gt;
shipped as Phase 1, and this post walks through the parts that were worth getting&lt;br&gt;
right: the single conversation-scoped read, its kind slices, cursor pagination, the&lt;br&gt;
resolver, a small FastAPI routing gotcha, and why encrypted chats are deliberately&lt;br&gt;
left out.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why scope the gallery to a conversation
&lt;/h2&gt;

&lt;p&gt;The obvious alternative is a user-scoped media index: "everything this person has&lt;br&gt;
ever shared, across all their chats." We didn't want that, for two reasons.&lt;/p&gt;

&lt;p&gt;First, the feature's meaning is always conversational. When you open someone's&lt;br&gt;
profile and tap &lt;strong&gt;Media&lt;/strong&gt;, you're asking "what have the two of us shared?" When you&lt;br&gt;
open group info, you're asking "what has this group shared?" Both questions are&lt;br&gt;
about a single conversation, and answering them with a per-user index would force us&lt;br&gt;
to reconstruct the conversation boundary in the query anyway.&lt;/p&gt;

&lt;p&gt;Second, access control falls out for free. A conversation already has a&lt;br&gt;
membership check on every read, so the gallery inherits it: if you're not a member,&lt;br&gt;
you can't page through the media. No new sharing model, no new leak surface.&lt;/p&gt;

&lt;p&gt;So the primitive is simple: given a conversation and a caller, return the messages&lt;br&gt;
in that conversation that carry media.&lt;/p&gt;
&lt;h2&gt;
  
  
  One read serves 1:1 and groups
&lt;/h2&gt;

&lt;p&gt;The backend exposes a single service method, &lt;code&gt;fetch_shared_media&lt;/code&gt;, and both entry&lt;br&gt;
points delegate to it:&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="nd"&gt;@staticmethod&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_shared_media&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;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;conversation_id&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;user_id&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;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;media&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&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="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Messages carrying media in a conversation (the &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Shared Media&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
    gallery shown on a profile and in group info).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It verifies the caller's access to the conversation, refuses encrypted threads (more&lt;br&gt;
on that below), filters by kind, and returns newest-first. The same call powers a&lt;br&gt;
1:1 profile tab and a group screen; there is no "group variant" and no "profile&lt;br&gt;
variant" on the read path.&lt;/p&gt;
&lt;h3&gt;
  
  
  The kind slices
&lt;/h3&gt;

&lt;p&gt;A gallery isn't one list — it's three, and the frontend shows them as segments. The&lt;br&gt;
&lt;code&gt;kind&lt;/code&gt; argument selects the slice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;image&lt;/code&gt;&lt;/strong&gt; — messages with an image attached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;video&lt;/code&gt;&lt;/strong&gt; — messages with a video attached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;media&lt;/code&gt;&lt;/strong&gt; (the default) — images &lt;em&gt;and&lt;/em&gt; videos, which is the photos grid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;file&lt;/code&gt;&lt;/strong&gt; — documents, i.e. messages carrying a file attachment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;link&lt;/code&gt;&lt;/strong&gt; — messages the backend attached a link preview to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The filters are plain column checks, which keeps the slice logic in one place and&lt;br&gt;
easy to read:&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;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;kind_filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;kind_filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;kind_filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;link&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;kind_filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;link_preview_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# "media" (default): images and videos
&lt;/span&gt;    &lt;span class="n"&gt;kind_filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;or_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&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;link&lt;/code&gt; is worth a note: it doesn't mean "message text contains a URL." It means the&lt;br&gt;
backend unfurled the URL and stored a preview, which is exactly what the link list&lt;br&gt;
renders.&lt;/p&gt;
&lt;h3&gt;
  
  
  Deleted messages, ordering, and the cursor
&lt;/h3&gt;

&lt;p&gt;Two invariants keep the list honest. Deleted messages are excluded outright —&lt;br&gt;
&lt;code&gt;deleted_at IS NULL&lt;/code&gt; is part of the &lt;code&gt;WHERE&lt;/code&gt;, so a removed photo doesn't linger in&lt;br&gt;
the grid. And results are ordered &lt;code&gt;Message.id.desc()&lt;/code&gt;, newest first, matching how&lt;br&gt;
every other list in the app reads.&lt;/p&gt;

&lt;p&gt;Pagination is a keyset cursor, not an offset: pass &lt;code&gt;before&lt;/code&gt;, the id of the oldest&lt;br&gt;
message you've already seen, and the query adds &lt;code&gt;Message.id &amp;lt; before&lt;/code&gt;. That stays&lt;br&gt;
correct while new messages arrive — an offset would shift rows out from under the&lt;br&gt;
reader as the conversation grows. The route takes the same three knobs, so the&lt;br&gt;
client can fetch the first page and then ask for older media on demand.&lt;/p&gt;

&lt;p&gt;There's one subtle, very real backend detail here. The response serializes the&lt;br&gt;
&lt;em&gt;reply&lt;/em&gt; sender's name, so &lt;code&gt;reply_to.sender&lt;/code&gt; is eager-loaded on the query. A lazy&lt;br&gt;
load inside an async session raises &lt;code&gt;MissingGreenlet&lt;/code&gt; at serialization time — the&lt;br&gt;
same bug family we hit on search. It's the kind of thing that only shows up when a&lt;br&gt;
gallery item happens to be a reply to an image, which makes it exactly the kind of&lt;br&gt;
thing to pin down in a query rather than discover in production.&lt;/p&gt;
&lt;h2&gt;
  
  
  A read-only peer resolver that never creates a conversation
&lt;/h2&gt;

&lt;p&gt;The profile screen knows a &lt;em&gt;user&lt;/em&gt; id, not a conversation id. Normally "start a chat"&lt;br&gt;
means get-or-create: look for the conversation, and if it isn't there, make one.&lt;br&gt;
That's the wrong contract for a profile tab. Opening someone's profile is a &lt;em&gt;read&lt;/em&gt;,&lt;br&gt;
and it shouldn't quietly create an empty conversation just because they glanced at a&lt;br&gt;
Media tab.&lt;/p&gt;

&lt;p&gt;So the peer path is a dedicated resolver, &lt;code&gt;fetch_shared_media_with_user&lt;/code&gt;, and it&lt;br&gt;
resolves without ever writing:&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="n"&gt;low&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;high&lt;/span&gt; &lt;span class="o"&gt;=&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="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;peer_id&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peer_id&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="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&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;Conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&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;Conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;private&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_encrypted&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="n"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_low&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_high&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;high&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asc&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conversation_id&lt;/span&gt; &lt;span class="o"&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;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="n"&gt;stmt&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;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;conversation_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ChatService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch_shared_media&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;conversation_id&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="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pair is normalized to an ordered &lt;code&gt;(low, high)&lt;/code&gt; so both directions of the same&lt;br&gt;
1:1 point at one row. The lookup is restricted to a &lt;em&gt;plaintext private&lt;/em&gt; conversation&lt;br&gt;
and returns an empty list when there isn't one — never a new row. From there it&lt;br&gt;
delegates to the same &lt;code&gt;fetch_shared_media&lt;/code&gt;, so the resolver only ever has to know how&lt;br&gt;
to find a conversation, not how to read its media.&lt;/p&gt;

&lt;p&gt;That "returns empty when none exists" behaviour is what makes the read idempotent:&lt;br&gt;
call it once or a hundred times, the state of the database is identical.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two routes, and the literal-first ordering gotcha
&lt;/h2&gt;

&lt;p&gt;The API exposes two endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /chat/with/{user_id}/media?kind=&amp;amp;limit=&amp;amp;before=
GET /chat/{conversation_id}/media?kind=&amp;amp;limit=&amp;amp;before=
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first is for profiles (peer id), the second for groups and chat info&lt;br&gt;
(conversation id). Both map onto the service methods above.&lt;/p&gt;

&lt;p&gt;Here's the gotcha, and it's a genuine one. The second route's path segment is&lt;br&gt;
&lt;code&gt;{conversation_id}&lt;/code&gt;, a placeholder that will happily match &lt;em&gt;any&lt;/em&gt; single segment —&lt;br&gt;
including the literal string &lt;code&gt;with&lt;/code&gt;. If it were declared first, a request to&lt;br&gt;
&lt;code&gt;/chat/with/123/media&lt;/code&gt; would match that placeholder, FastAPI would then try to&lt;br&gt;
validate &lt;code&gt;with&lt;/code&gt; as an integer, and the client would get a &lt;code&gt;422&lt;/code&gt; rather than the&lt;br&gt;
profile gallery. FastAPI resolves routes in declaration order and validates the path&lt;br&gt;
parameter's type &lt;em&gt;after&lt;/em&gt; a route matches, so a later literal route is simply never&lt;br&gt;
reached. The rule that follows is boring and effective: &lt;strong&gt;declare the literal,&lt;br&gt;
more-specific route first.&lt;/strong&gt; We do, and the &lt;code&gt;with&lt;/code&gt; segment can never be mistaken for&lt;br&gt;
a conversation id. The FastAPI docs on &lt;a href="https://fastapi.tiangolo.com/tutorial/path-params/" rel="noopener noreferrer"&gt;path parameters and their&lt;br&gt;
order&lt;/a&gt; are worth a read if you&lt;br&gt;
haven't been bitten by this yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encrypted chats are deliberately excluded
&lt;/h2&gt;

&lt;p&gt;We ship opt-in end-to-end encrypted chats. Their media is encrypted on the client&lt;br&gt;
before it ever reaches the server, which means the server is holding ciphertext — and&lt;br&gt;
ciphertext can't be filtered or listed. There is no "is this an image?" column to&lt;br&gt;
query, because the image is inside a blob the server can't read.&lt;/p&gt;

&lt;p&gt;So the feature is honest about it in two different places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;conversation-scoped&lt;/strong&gt; read checks whether the conversation is encrypted and
returns &lt;strong&gt;403&lt;/strong&gt; if it is — "Shared media is not available in encrypted chats." An
explicit refusal beats a silently empty list that looks like a bug.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;peer resolver&lt;/strong&gt; only ever looks at the plaintext private conversation, so a
profile Media tab for someone you only share an encrypted chat with simply returns
nothing. It doesn't 403, because from the resolver's point of view the two of you
have no plaintext conversation — which is true.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Making this work later means decrypting from the local cache on the client, a&lt;br&gt;
separate effort we've noted rather than bolted on. Some things are worth doing once,&lt;br&gt;
properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The frontend: Media / Files / Links
&lt;/h2&gt;

&lt;p&gt;The Flutter side is a single reusable widget, &lt;code&gt;SharedMediaView&lt;/code&gt;. It takes &lt;em&gt;exactly&lt;br&gt;
one&lt;/em&gt; of &lt;code&gt;userId&lt;/code&gt; (profile path) or &lt;code&gt;conversationId&lt;/code&gt; (group path), asserted at&lt;br&gt;
construction, and stores three lazy segment caches so switching tabs doesn't re-fetch&lt;br&gt;
what you've already seen.&lt;/p&gt;

&lt;p&gt;The segments are Media, Files and Links, switched with Material's&lt;br&gt;
&lt;a href="https://api.flutter.dev/flutter/material/SegmentedButton-class.html" rel="noopener noreferrer"&gt;&lt;code&gt;SegmentedButton&lt;/code&gt;&lt;/a&gt;,&lt;br&gt;
and the Media segment renders a 3-up grid that reuses the pieces the rest of the app&lt;br&gt;
already has: &lt;code&gt;CachedNetworkImage&lt;/code&gt; backed&lt;br&gt;
by the shared &lt;code&gt;AppMediaCacheManager&lt;/code&gt; for cached thumbnails, and the existing&lt;br&gt;
fullscreen gallery for the tap-through, so the gallery you get here behaves exactly&lt;br&gt;
like the one in a chat. Videos render a play-button placeholder in the grid and open&lt;br&gt;
in the same viewer.&lt;/p&gt;

&lt;p&gt;Files and links are lists. A file row shows the file name and a human-readable size&lt;br&gt;
and opens in the browser on tap. A link row leads with the unfurled thumbnail, then&lt;br&gt;
the preview title, with the site name underneath — the same data the link-preview&lt;br&gt;
message bubble uses, which is why the &lt;code&gt;link&lt;/code&gt; slice keys off the stored preview rather&lt;br&gt;
than parsing text.&lt;/p&gt;

&lt;p&gt;For entry points that aren't already a tab — pushing the gallery from group info — a&lt;br&gt;
thin &lt;code&gt;SharedMediaScreen&lt;/code&gt; hosts the widget full-screen. The profile reuses its tab&lt;br&gt;
framework, adding an optional &lt;strong&gt;Media&lt;/strong&gt; tab scoped by &lt;code&gt;userId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you like the media plumbing behind this, two sibling posts go deeper: &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-photo-albums-in-flutter.html" rel="noopener noreferrer"&gt;photo&lt;br&gt;
albums in&lt;br&gt;
Flutter&lt;/a&gt;&lt;br&gt;
covers the multi-image layout, and &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-flutter-linux-desktop-video-media-kit.html" rel="noopener noreferrer"&gt;the Flutter Linux desktop video and media&lt;br&gt;
kit&lt;/a&gt;&lt;br&gt;
covers playback. The &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-13-linux-keyring-session-durability.html" rel="noopener noreferrer"&gt;session durability work with the Linux&lt;br&gt;
keyring&lt;/a&gt;&lt;br&gt;
is the other half of getting desktop right, and there's a &lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-11-what-is-tongues-language-exchange-messenger.html" rel="noopener noreferrer"&gt;full overview of&lt;br&gt;
GwinG&lt;/a&gt;&lt;br&gt;
if you're new here.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does opening someone's profile create a chat?&lt;/strong&gt;&lt;br&gt;
No. The profile path goes through the read-only peer resolver, which returns an empty&lt;br&gt;
list when the two of you have no plaintext conversation. It never writes a row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens on an encrypted chat?&lt;/strong&gt;&lt;br&gt;
The conversation-scoped endpoint returns &lt;strong&gt;403&lt;/strong&gt; — an explicit "not available here."&lt;br&gt;
The profile path returns an empty list, because the resolver only consults the&lt;br&gt;
plaintext 1:1 conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the gallery show media from a group and a 1:1 the same way?&lt;/strong&gt;&lt;br&gt;
Yes. Both are the same conversation-scoped read; the only difference is whether the&lt;br&gt;
client passes a &lt;code&gt;conversationId&lt;/code&gt; or resolves one from a &lt;code&gt;userId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do deleted messages still show up?&lt;/strong&gt;&lt;br&gt;
No. Deleted messages are excluded at the query level, so a removed photo disappears&lt;br&gt;
from the grid on the next load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I page further back through old media?&lt;/strong&gt;&lt;br&gt;
The API is cursor-paginated by message id via &lt;code&gt;before&lt;/code&gt;, newest-first. The Phase 1 UI&lt;br&gt;
loads the first page; wiring the grid's scroll to pull older pages is a small&lt;br&gt;
follow-up on the same cursor.&lt;/p&gt;

&lt;p&gt;— the GwinG team&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>flutter</category>
      <category>fastapi</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Replacing a Flutter app bar with a FAB action stack</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Tue, 13 Oct 2026 11:27:04 +0000</pubDate>
      <link>https://dev.to/frankmilvus/replacing-a-flutter-app-bar-with-a-fab-action-stack-14g0</link>
      <guid>https://dev.to/frankmilvus/replacing-a-flutter-app-bar-with-a-fab-action-stack-14g0</guid>
      <description>&lt;p&gt;The &lt;strong&gt;GwinG&lt;/strong&gt; notification center is a short screen: reactions, profile views&lt;br&gt;
and group events, most recent first. It opened from the Friends header's overflow&lt;br&gt;
menu and carried a perfectly ordinary &lt;code&gt;AppBar&lt;/code&gt; — a "Notifications" title on the&lt;br&gt;
left, a &lt;strong&gt;mark all read&lt;/strong&gt; and a &lt;strong&gt;clear all&lt;/strong&gt; icon button on the right.&lt;/p&gt;

&lt;p&gt;It worked. It also looked like every other screen, and it put the three things&lt;br&gt;
you actually do on that screen — leave it, mark it read, clear it — into small&lt;br&gt;
icon buttons at the very top of the phone, the hardest place to reach.&lt;/p&gt;

&lt;p&gt;So we deleted the app bar. In this post I'll walk through what replaced it: a&lt;br&gt;
vertical &lt;strong&gt;floating action button&lt;/strong&gt; stack, why a Scaffold with no &lt;code&gt;AppBar&lt;/code&gt; loses&lt;br&gt;
its managed status bar (and the zero-height trick that gets it back), and the two&lt;br&gt;
gotchas that bite the moment you put more than one FAB on a route.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why take the app bar out
&lt;/h2&gt;

&lt;p&gt;Three reasons, in order of how much they actually mattered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reachability.&lt;/strong&gt; On a phone, top-right icon buttons are a stretch. The bottom
third of the screen is where a thumb lives, and that is FAB territory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency.&lt;/strong&gt; The app already had a house style for floating buttons — an
outlined circle in the brand colour — used across the Friends page, the profile
screen and the full-screen map. The notification center was one of the few
screens still speaking a different language.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less chrome.&lt;/strong&gt; The title just said "Notifications" on the screen that the
notification icon opened. It was a label telling you what you already knew, in
the one row where vertical space is scarcest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The plan was simple to state: drop the toolbar, and render &lt;strong&gt;back&lt;/strong&gt;,&lt;br&gt;
&lt;strong&gt;mark all read&lt;/strong&gt; and &lt;strong&gt;clear all&lt;/strong&gt; as the same floating buttons used everywhere&lt;br&gt;
else.&lt;/p&gt;
&lt;h2&gt;
  
  
  The catch: no AppBar means no managed status bar
&lt;/h2&gt;

&lt;p&gt;Here is the part that surprises people. A &lt;code&gt;Scaffold&lt;/code&gt; with an &lt;code&gt;AppBar&lt;/code&gt; doesn't just&lt;br&gt;
draw a toolbar — it also lets Flutter manage the &lt;strong&gt;status bar&lt;/strong&gt; for you: the&lt;br&gt;
system bar behind the clock and battery gets the page's colour and the right&lt;br&gt;
icon brightness, consistently across every screen.&lt;/p&gt;

&lt;p&gt;Take the &lt;code&gt;AppBar&lt;/code&gt; away and that management goes with it. On Android especially,&lt;br&gt;
you can end up with a status bar whose colour no longer matches the page, or&lt;br&gt;
whose icons are dark-on-dark. It's the kind of thing that looks like a bug in the&lt;br&gt;
OS rather than in your layout.&lt;/p&gt;

&lt;p&gt;The fix is not to fight it — it's to keep a toolbar that draws nothing:&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="c1"&gt;// A managed status bar without a toolbar. This still reserves the&lt;/span&gt;
  &lt;span class="c1"&gt;// status-bar inset and lets Flutter colour the bar and pick the icon&lt;/span&gt;
  &lt;span class="c1"&gt;// brightness, exactly as a normal AppBar would.&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="nl"&gt;elevation:&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;scrolledUnderElevation:&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;shape:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;RoundedRectangleBorder&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;body:&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 zero-height &lt;code&gt;AppBar&lt;/code&gt; keeps everything you want from the bar — the status-bar&lt;br&gt;
inset and the managed system chrome — and draws none of it. We already lean on&lt;br&gt;
this same trick on the Friends page (which draws its own custom header below) and&lt;br&gt;
on the full-screen map, so the notification center was a natural fit.&lt;/p&gt;
&lt;h2&gt;
  
  
  One slot, a whole stack of buttons
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Scaffold&lt;/code&gt; gives you a single &lt;code&gt;floatingActionButton&lt;/code&gt;. You are not limited to one&lt;br&gt;
button, though — the slot takes any widget, so you stack them in a &lt;code&gt;Column&lt;/code&gt; that&lt;br&gt;
grows upward from the bottom-right corner:&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;floatingActionButton:&lt;/span&gt; &lt;span class="n"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;mainAxisSize:&lt;/span&gt; &lt;span class="n"&gt;MainAxisSize&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;crossAxisAlignment:&lt;/span&gt; &lt;span class="n"&gt;CrossAxisAlignment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;end&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="n"&gt;AppFab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;heroTag:&lt;/span&gt; &lt;span class="s"&gt;'notif_clear_all'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;onPressed:&lt;/span&gt; &lt;span class="n"&gt;_items&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotEmpty&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_confirmClearAll&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="nl"&gt;tooltip:&lt;/span&gt; &lt;span class="n"&gt;l10n&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clearAll&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;icon:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Icons&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delete_sweep_outlined&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;AppFab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;heroTag:&lt;/span&gt; &lt;span class="s"&gt;'notif_mark_all_read'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;onPressed:&lt;/span&gt; &lt;span class="n"&gt;_items&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;n&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isRead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_markAllRead&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="nl"&gt;tooltip:&lt;/span&gt; &lt;span class="n"&gt;l10n&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markAllRead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;icon:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Icons&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;done_all&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;AppFab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;heroTag:&lt;/span&gt; &lt;span class="s"&gt;'notif_back'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;onPressed:&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;Navigator&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;pop&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="nl"&gt;tooltip:&lt;/span&gt; &lt;span class="n"&gt;MaterialLocalizations&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;backButtonTooltip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;icon:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Icons&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;arrow_back&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;Two deliberate choices there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing moves.&lt;/strong&gt; All three buttons are always mounted, so the stack's layout
is fixed. When an action has nothing to act on — clear all on an empty list,
mark all read when everything is already read — it stays put and greys out
instead of vanishing. A button that comes and goes makes the one below it
jump, and that motion is exactly what makes a UI feel unstable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The list gets out of the way.&lt;/strong&gt; The FAB stack floats over the content, so the
list carries bottom padding equal to roughly its height. Without it, the last
notification sits under the buttons.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;AppFab&lt;/code&gt; is our shared wrapper around &lt;code&gt;FloatingActionButton&lt;/code&gt; — a circular outline&lt;br&gt;
in the brand colour over the surface, with a brand-coloured icon. Routing every&lt;br&gt;
FAB through it is what keeps the app from drifting into a mix of Material's tonal&lt;br&gt;
default (which reads as a rounded square in a different colour) and the house&lt;br&gt;
style.&lt;/p&gt;

&lt;p&gt;Passing &lt;code&gt;null&lt;/code&gt; for &lt;code&gt;onPressed&lt;/code&gt; is the idiomatic way to disable a Material button,&lt;br&gt;
and it does make the FAB non-tappable — but it does &lt;strong&gt;not&lt;/strong&gt; grey it out.&lt;br&gt;
&lt;code&gt;FloatingActionButton&lt;/code&gt; only falls back to its disabled colours when you leave&lt;br&gt;
&lt;code&gt;backgroundColor&lt;/code&gt; and &lt;code&gt;foregroundColor&lt;/code&gt; unset; set both explicitly, as the house&lt;br&gt;
style does, and a disabled FAB keeps the full brand colour — looking enabled while&lt;br&gt;
doing nothing. So &lt;code&gt;AppFab&lt;/code&gt; reads &lt;code&gt;onPressed == null&lt;/code&gt; itself and swaps to muted&lt;br&gt;
surface-variant greys for the outline and the icon:&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;enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;onPressed&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;final&lt;/span&gt; &lt;span class="n"&gt;accent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onSurfaceVariant&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;border&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;outlineVariant&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gotcha #1: two FABs on a route trip the hero-tag check
&lt;/h2&gt;

&lt;p&gt;This one is not obvious until it breaks.&lt;/p&gt;

&lt;p&gt;Every &lt;code&gt;FloatingActionButton&lt;/code&gt; participates in Flutter's &lt;a href="https://api.flutter.dev/flutter/material/Hero-class.html" rel="noopener noreferrer"&gt;Hero&lt;/a&gt;&lt;br&gt;
machinery for route transitions. When you don't give a FAB a &lt;code&gt;heroTag&lt;/code&gt;, it gets a&lt;br&gt;
&lt;strong&gt;single shared default tag&lt;/strong&gt; — the same constant for &lt;em&gt;every&lt;/em&gt; untagged FAB in the&lt;br&gt;
app. With one FAB per screen that's fine. Put three on one route, and you have&lt;br&gt;
three heroes claiming the same tag: Flutter throws a duplicate-tag assertion, and&lt;br&gt;
even when it doesn't, route transitions animate the wrong button across the&lt;br&gt;
screen.&lt;/p&gt;

&lt;p&gt;The rule we landed on is boring and effective: &lt;strong&gt;every FAB gets an explicit,&lt;br&gt;
unique &lt;code&gt;heroTag&lt;/code&gt;.&lt;/strong&gt; Note that "unique" has to hold beyond this route, too — the&lt;br&gt;
Friends page's own FABs are still mounted underneath while the notification center&lt;br&gt;
pushes on top of them, so the tags must not collide across both screens.&lt;/p&gt;
&lt;h2&gt;
  
  
  Gotcha #2: making an icon-only button say what it is
&lt;/h2&gt;

&lt;p&gt;An icon-only &lt;code&gt;FloatingActionButton&lt;/code&gt; has no text for a screen reader and no label&lt;br&gt;
for a long-press tooltip, so it should always carry one. For the two app actions&lt;br&gt;
we had localized strings already. For &lt;strong&gt;back&lt;/strong&gt;, we didn't — and it turns out we&lt;br&gt;
never needed to add one:&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;tooltip:&lt;/span&gt; &lt;span class="n"&gt;MaterialLocalizations&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;backButtonTooltip&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;MaterialLocalizations&lt;/code&gt; ships a translated &lt;code&gt;backButtonTooltip&lt;/code&gt; for every locale&lt;br&gt;
Flutter supports, which is exactly the string a real &lt;code&gt;AppBar&lt;/code&gt; back button would&lt;br&gt;
use. Borrowing it keeps one source of truth for "Back" across the whole app&lt;br&gt;
instead of a fifth translation that can drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The notification center is now a clean list with three outlined circles parked in&lt;br&gt;
thumb range — &lt;strong&gt;back&lt;/strong&gt;, &lt;strong&gt;mark all read&lt;/strong&gt;, and above it &lt;strong&gt;clear all&lt;/strong&gt; — that grey&lt;br&gt;
out whenever there is nothing for them to do. The status bar stays the page's&lt;br&gt;
colour, back works from the bottom-right the way it does on the full-screen map,&lt;br&gt;
and the screen finally looks like it belongs to the same app as everything around&lt;br&gt;
it.&lt;/p&gt;

&lt;p&gt;It's a small change. But "the app bar can just be, well, not there" is one of&lt;br&gt;
those realisations that quietly raises the floor on every screen you touch&lt;br&gt;
afterwards.&lt;/p&gt;

&lt;p&gt;If you want the surrounding context, the Friendship flow and the&lt;br&gt;
&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;connection status work&lt;/a&gt;&lt;br&gt;
we shipped just before this are both written up on the blog, and there's a&lt;br&gt;
&lt;a href="https://frankmilvus.github.io/tongues-articles/articles/2026-10-11-what-is-tongues-language-exchange-messenger.html" rel="noopener noreferrer"&gt;full overview of GwinG&lt;/a&gt;&lt;br&gt;
if you're new here.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does removing the app bar break the system back button?&lt;/strong&gt;&lt;br&gt;
No. The system back gesture and button are handled by the route, not the &lt;code&gt;AppBar&lt;/code&gt;.&lt;br&gt;
Removing the toolbar only removes the &lt;em&gt;on-screen&lt;/em&gt; affordance — which is exactly&lt;br&gt;
why we added the back FAB. Both work, and Android's predictive back is unaffected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just use a &lt;code&gt;BottomAppBar&lt;/code&gt; or &lt;code&gt;PersistentFooterButtons&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
Either works. We chose a FAB stack because the app already has an established FAB&lt;br&gt;
style on the Friends page, and reusing it made the notification center match&lt;br&gt;
without introducing a fourth bottom-bar pattern to keep consistent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you have more than one &lt;code&gt;FloatingActionButton&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
Yes — the &lt;code&gt;Scaffold&lt;/code&gt; takes one &lt;code&gt;floatingActionButton&lt;/code&gt; widget, but that widget can&lt;br&gt;
be a &lt;code&gt;Column&lt;/code&gt;, a &lt;code&gt;Row&lt;/code&gt; or any layout containing several buttons. Just give each a&lt;br&gt;
unique &lt;code&gt;heroTag&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the zero-height &lt;code&gt;AppBar&lt;/code&gt; still push the body down?&lt;/strong&gt;&lt;br&gt;
It reserves the status-bar inset (so content doesn't slide under the clock) but no&lt;br&gt;
toolbar height, so the body starts right below the system status bar. We still&lt;br&gt;
wrap the body in &lt;code&gt;SafeArea(top: true)&lt;/code&gt; for the remaining insets.&lt;/p&gt;

&lt;p&gt;— the GwinG team&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>flutter</category>
      <category>ux</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Small polish, better feel — a smoother connection status and a clear way out of a call</title>
      <dc:creator>Frank Milvus</dc:creator>
      <pubDate>Mon, 12 Oct 2026 13:57:59 +0000</pubDate>
      <link>https://dev.to/frankmilvus/small-polish-better-feel-a-smoother-connection-status-and-a-clear-way-out-of-a-call-10fi</link>
      <guid>https://dev.to/frankmilvus/small-polish-better-feel-a-smoother-connection-status-and-a-clear-way-out-of-a-call-10fi</guid>
      <description>&lt;p&gt;Today's changes in &lt;strong&gt;GwinG&lt;/strong&gt; were small on purpose. Neither is a headline&lt;br&gt;
feature — but they're the kind of thing you notice the moment it's right: the app&lt;br&gt;
feels calmer and gets out of your way.&lt;/p&gt;

&lt;h2&gt;
  
  
  A connection status that fades, not pops
&lt;/h2&gt;

&lt;p&gt;Next to the Friends wordmark we show a live connection label — &lt;em&gt;Connecting…&lt;/em&gt;,&lt;br&gt;
&lt;em&gt;Syncing…&lt;/em&gt;, or &lt;em&gt;No connection&lt;/em&gt;. It used to appear and disappear in a single&lt;br&gt;
frame, which read as a hard blink every time the network changed its mind.&lt;/p&gt;

&lt;p&gt;It now &lt;strong&gt;fades and slides&lt;/strong&gt; in and out instead of popping, and because it's a&lt;br&gt;
visibility change rather than an entrance, we made it something you control:&lt;br&gt;
there's a new &lt;strong&gt;Connection status&lt;/strong&gt; toggle under &lt;strong&gt;Settings → Motion&lt;/strong&gt;, joining&lt;br&gt;
the existing motion categories (nine now). When motion is switched off, the label&lt;br&gt;
simply updates instantly — no special-casing needed.&lt;/p&gt;

&lt;p&gt;A couple of details we cared about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The label only cross-fades when it appears or disappears. Switching between two
live states updates in place, so you don't get a distracting flicker between
similar words.&lt;/li&gt;
&lt;li&gt;The wordmark never shifts. The slot is always laid out, so the header stays put
and the text keeps sitting exactly on the wordmark's baseline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A clear way out of a call
&lt;/h2&gt;

&lt;p&gt;When a call ends, GwinG shows a short &lt;strong&gt;end card&lt;/strong&gt; — who it was with, how it&lt;br&gt;
ended, how long it lasted, and a &lt;strong&gt;Call again&lt;/strong&gt; button. The card returns you to&lt;br&gt;
the app on its own after a few seconds (a little longer when there's a reason to&lt;br&gt;
read), and tapping anywhere dismisses it early.&lt;/p&gt;

&lt;p&gt;The catch: "tap anywhere" is not the same as an obvious way out. So we added a&lt;br&gt;
&lt;strong&gt;Back&lt;/strong&gt; button right next to &lt;em&gt;Call again&lt;/em&gt;. Now leaving the end card is a named,&lt;br&gt;
one-tap action instead of a hidden gesture or a short wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we bother with the small stuff
&lt;/h2&gt;

&lt;p&gt;A messenger is mostly made of these moments — a label that blinks, a screen you&lt;br&gt;
can't quite leave. Big features get the attention, but the small ones are what&lt;br&gt;
make an app feel considered. We write them up as we ship them, so the polish&lt;br&gt;
gets its share of the credit too.&lt;/p&gt;

&lt;p&gt;— the GwinG team&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>flutter</category>
      <category>showdev</category>
      <category>ux</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
