<?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: Cesare Bramante</title>
    <description>The latest articles on DEV Community by Cesare Bramante (@cbramante).</description>
    <link>https://dev.to/cbramante</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%2F4150476%2Fe9797120-746b-4a69-911f-85df4de6afcc.png</url>
      <title>DEV Community: Cesare Bramante</title>
      <link>https://dev.to/cbramante</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cbramante"/>
    <language>en</language>
    <item>
      <title>Every revision made my app spec better. The fix was one rule about data.</title>
      <dc:creator>Cesare Bramante</dc:creator>
      <pubDate>Wed, 14 Oct 2026 04:28:03 +0000</pubDate>
      <link>https://dev.to/cbramante/every-revision-made-my-app-spec-better-the-fix-was-one-rule-about-data-223g</link>
      <guid>https://dev.to/cbramante/every-revision-made-my-app-spec-better-the-fix-was-one-rule-about-data-223g</guid>
      <description>&lt;p&gt;I shipped a native app on both stores, in five languages, on my own, with a general-purpose AI as my developer. Between my first prompt and the first screen that did what I wanted, there were six versions of a "master prompt". This episode is about those versions, and about why the fix I needed wasn't in any of them.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/cbramante/i-gave-an-ai-my-first-app-spec-it-cut-half-of-it-and-took-the-other-half-too-literally-31i6"&gt;the first episode&lt;/a&gt; I told how the AI took my first line literally: I wrote "share your position", and it gave me a map of friends. This is what happened next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The master prompt
&lt;/h2&gt;

&lt;p&gt;Every chat with an AI starts from zero. So from the first week I kept one document that I pasted at the start of every new session: what the app is, who it's for, how it works, which decisions are already made. I called it the master prompt.&lt;/p&gt;

&lt;p&gt;It changed nature faster than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;January 26:&lt;/strong&gt; vision, user flow, colors. Basically my first prompt plus the AI's answer, cleaned up. One line in capitals that I still like: "remove the word 'invite' completely".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;February 13:&lt;/strong&gt; a technical spec. Tables, pages, triggers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;February 18:&lt;/strong&gt; the same spec rewritten in FlutterFlow's own vocabulary: page state, actions, conditional visibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same day, seven minutes apart:&lt;/strong&gt; a new version "synced with what I actually built in the editor".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one was the real change. The master prompt stopped being a description of what I wanted and became a photo of what existed. An AI that reads a wish will build the wish. An AI that reads a photo will start from where you are.&lt;/p&gt;

&lt;p&gt;By spring I had two branches of the document, numbered in a way that makes no sense today (a v4.0 in March, then a v3.2 and v3.3 in April and June). More on that in episode 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  The spec that ran every ten seconds
&lt;/h2&gt;

&lt;p&gt;In late January the AI generated two files for me and called them FlutterFlow exports. The simple one had exactly the five screens it had recommended. The "advanced" one had this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"update_live_location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"runs every 10 seconds"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"share_location_event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"writes to locationShares"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"join_event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"adds user to events.participants"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus friends' pins on the map, a live location field on every user, and a collection to share your position with an expiry.&lt;/p&gt;

&lt;p&gt;Three problems, in increasing order of importance:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Battery.&lt;/strong&gt; A position update every ten seconds is the fastest way to get uninstalled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stores.&lt;/strong&gt; Both stores are strict about location in the background as a main feature. The app would have had a hard time getting approved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It was the app I didn't want.&lt;/strong&gt; People on the map, not events.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And a fourth one, smaller and funnier: the file couldn't be imported in my version of FlutterFlow, and in the same configuration it mixed a Mapbox style address into a MapLibre map. A spec that looked perfectly plausible and couldn't run anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  I also tried FlutterFlow's own AI
&lt;/h2&gt;

&lt;p&gt;In February I tried FlutterFlow's built-in page generator for the first screens. One of my prompts started with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Create a map UI like Zenly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Zenly was the best-known app for seeing your friends on a map. So I was asking a second AI to build, again, the version of my idea I didn't want. The generator was quick for empty layouts. I didn't like what it gave me, so I built the pages myself in the editor. The logic code came from the general-purpose AI, and FlutterFlow compiled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was one line about data
&lt;/h2&gt;

&lt;p&gt;Rewriting the pitch didn't change the direction. Every new answer still leaned on "position". What changed it was a rule in the data model.&lt;/p&gt;

&lt;p&gt;On February 13 a line appeared in the spec: no position saved outside an active event. On February 18 the users table no longer had any location field at all. A position existed only as the place of an event, and the event had an expiry date. The cloud database became, in the words of that spec, a bulletin board for pins on a map.&lt;/p&gt;

&lt;p&gt;After that, the AI could not drift back even if it wanted to. There was simply nowhere to store where a person is. You can argue with an AI about intent for weeks. A missing field ends the argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  The revision loop that came later
&lt;/h2&gt;

&lt;p&gt;The real cycles of revision started in April, with a working app and a second AI reviewing the code. In one week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;April 22: first audit;&lt;/li&gt;
&lt;li&gt;April 23: nineteen fixes;&lt;/li&gt;
&lt;li&gt;April 24: second audit, 23 problems;&lt;/li&gt;
&lt;li&gt;April 26-27: final audit before release, 31 bugs;&lt;/li&gt;
&lt;li&gt;April 30: verification. Eleven of the thirteen "pending" fixes were already in the code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The documentation was behind the code, not the other way around. From then on the rule was: when the code and the document disagree, the code wins. And one more, learned the hard way: a fix declared "applied" must be checked against the previous export, because one of them had introduced a new bug.&lt;/p&gt;

&lt;p&gt;To keep the document up to date I ended up with a very short prompt, which I still use (translated from Italian):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scan &amp;lt;chat&amp;gt; and &amp;lt;audit&amp;gt;, extract every fix and decision.
Output: ONLY a diff 🟢/🟡/🔴 per section. No preamble. Nothing unchanged.
Priority: audit &amp;gt; chat &amp;gt; document.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Revisions improve the form of a spec. They rarely fix its intent.&lt;/li&gt;
&lt;li&gt;If your idea goes against the common version, put it in the data model, not only in the description. A rule on data is the one thing the AI can't reinterpret.&lt;/li&gt;
&lt;li&gt;Keep your main prompt as a photo of what exists, not a list of what you want.&lt;/li&gt;
&lt;li&gt;When the code and the document disagree, believe the code. Then fix the document.&lt;/li&gt;
&lt;li&gt;A generated spec has to be read line by line. "Every 10 seconds" was right there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next episode: the first sketch. Colors, a font chosen on day one, a logo I threw away, and five languages that turned out to be the hardest part.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building orbiTome solo, with FlutterFlow and Firebase. More at &lt;a href="https://www.orbitome.com" rel="noopener noreferrer"&gt;orbitome.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>flutter</category>
      <category>firebase</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I gave an AI my first app spec. It cut half of it and took the other half too literally</title>
      <dc:creator>Cesare Bramante</dc:creator>
      <pubDate>Thu, 08 Oct 2026 07:58:06 +0000</pubDate>
      <link>https://dev.to/cbramante/i-gave-an-ai-my-first-app-spec-it-cut-half-of-it-and-took-the-other-half-too-literally-31i6</link>
      <guid>https://dev.to/cbramante/i-gave-an-ai-my-first-app-spec-it-cut-half-of-it-and-took-the-other-half-too-literally-31i6</guid>
      <description>&lt;p&gt;A native app on both stores, in five languages, built by one person with an AI as the developer. This is how it started: the day an AI took my first spec apart.&lt;/p&gt;

&lt;p&gt;"Computers are good at following instructions, but not at reading your mind." Donald Knuth, 1984&lt;/p&gt;

&lt;p&gt;Forty years later, it turns out this is still true when the computer is an AI.&lt;/p&gt;

&lt;p&gt;I'm a solo developer. I didn't write the app the classic way: a general-purpose AI was my developer, and FlutterFlow was my compiler. This series is how that went, from the first prompt to the stores, including the parts that didn't work.&lt;/p&gt;

&lt;p&gt;This first episode is about the idea, and about what the AI did to it.&lt;/p&gt;

&lt;p&gt;Who I am, briefly&lt;/p&gt;

&lt;p&gt;I'm 56 and I come from IT. I started as a computer technician, then worked as an IT security consultant, then spent years building web portals. For a while I also taught at university as a subject expert, without a degree. So I'm not new to software, but I had never built a mobile app. I work alone, and the app is meant for people a lot younger than me.&lt;/p&gt;

&lt;p&gt;Where the idea came from&lt;/p&gt;

&lt;p&gt;Having kids, and looking around me, I had the feeling that people see each other less. Not because they don't want to, but because organizing anything takes a group chat, fifty messages and a decision nobody wants to make.&lt;/p&gt;

&lt;p&gt;And most social apps push the other way. They are built to keep you on the phone, and they reward showing a life other people can't reach. I wanted an app you open in order to put the phone down: you see where something is happening, you go.&lt;/p&gt;

&lt;p&gt;I looked for an app that did this. Some had been born, had an early success and then closed. Others were very niche: only parties, only sports, only public events. Nothing for the simple case: a few friends who want to meet tonight.&lt;/p&gt;

&lt;p&gt;So I installed a bunch of these apps, used them for a while, and in January I wrote my first prompt, to Gemini.&lt;/p&gt;

&lt;p&gt;The first prompt&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fovwbph7627t6r0ilq3lr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fovwbph7627t6r0ilq3lr.png" alt="Italian text of the original app spec, starting with the goal: sharing your current and future position with friends and groups" width="799" height="367"&gt;&lt;/a&gt; My original prompt to Gemini, January 2026 (in Italian). The first line says "share your current and future position".&lt;/p&gt;

&lt;p&gt;It was a full spec. Contacts imported from the phone and turned into tags. Three states for every event: "I'm here", "I'll be there", "I'd like to go". Email invites for people without the app. A cloud backup. Five languages to pick from at install. A color palette, a font, even the ad banner position.&lt;/p&gt;

&lt;p&gt;Reading it today, it's a wish list. And the first line has a problem I didn't see at the time.&lt;/p&gt;

&lt;p&gt;What the AI told me&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftlp9lcq5zdmdnv8t5fnt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftlp9lcq5zdmdnv8t5fnt.png" alt="Italian text of the AI's answer: the idea is clear but too complex, with the golden rule " width="800" height="410"&gt;&lt;/a&gt; The beginning of the AI's answer (in Italian). The bold line at the end is the rule I still use.&lt;/p&gt;

&lt;p&gt;The answer started kindly and then went straight to the point: the idea was clear, but too complex for the value people would perceive. The user had to understand too much before getting anything back. And then one sentence that I still use as a test for every new feature:&lt;/p&gt;

&lt;p&gt;In 30 seconds I must be able to say "I'm here" and invite someone.&lt;/p&gt;

&lt;p&gt;Then it went through everything. Onboarding in three steps at most. The map as the home screen, not as a feature. Tags that look like groups with an emoji, not like a database field. Invites through a link you can paste in any chat, with email only as a fallback. Cut the manual backup, cut the advanced history, use the system language.&lt;/p&gt;

&lt;p&gt;It also took my first line at face value. I had written "share your position", and it built on that: friends' avatars visible on the map, a "social map" where you see who is going where, a live "I'm here" position with a two hour timer. To be fair, that is exactly what my words said. It wasn't what I meant.&lt;/p&gt;

&lt;p&gt;What survived, and what didn't&lt;/p&gt;

&lt;p&gt;Looking at the app that is on the stores today:&lt;/p&gt;

&lt;p&gt;Kept: the map as the home screen, with different pins for different kinds of plans. The invite link, which became the most important feature: whoever opens it sees the place, the time and who's coming, even without installing anything. Tags became groups, and you can filter the map by group. Every event got a QR code. A ghost mode, although today it means something else: the event is visible only to the people you invited. And "I'd like to go", which became the Wish: you post an idea and see who's in before organizing anything.&lt;br&gt;
Kept, but moved: the idea of a timer. It's no longer on my position, it's on the event: an event leaves the cloud shortly after it ends.&lt;br&gt;
Refused: the advanced history cut. I kept the history, but it stays on the phone, not in the cloud.&lt;br&gt;
Dropped, further than the AI suggested: email invites. The AI wanted email as a fallback; I removed it completely, because people get too many emails and ignore most of them. I looked at SMS too, but it felt archaic and every message costs money. And sending invites through messaging apps meant the answers would come back scattered across different chats, with no way to collect them. A link solved both problems: it travels in any chat, and you see who's coming in the event, not in a thread.&lt;br&gt;
Dropped: the viral mechanics. Some were mine (invite three friends to unlock premium), some were the AI's (badges).&lt;br&gt;
Refused outright: people on the map. It was the one thing I was sure about.&lt;br&gt;
Ignored, then came back to: languages. I wanted five, and I kept five. But at first I only filled in FlutterFlow's language panel, and it turned out to be a dead end: snackbars and alerts weren't translated. Today the app follows the localization of the device, with English as a fallback for the few strings I missed. Not far from what the AI had suggested on day one.&lt;br&gt;
The part I only understood later&lt;/p&gt;

&lt;p&gt;The cuts were the easy part. The real lesson was in the first line.&lt;/p&gt;

&lt;p&gt;"Share your position" is what location sharing apps do, so the AI gave me the most common version of that idea, and every following answer built on it. What I wanted was almost the opposite: show where things happen, not where people are. The AI read my words, not my intent. It took me months, and a lot of rewriting, to make the spec say what I meant.&lt;/p&gt;

&lt;p&gt;What I took from it&lt;br&gt;
A first spec is a wish list. Writing it is useful; building it as it is, isn't.&lt;br&gt;
AI is very good at cutting. It has no attachment to your features.&lt;br&gt;
AI fills every ambiguity with the most common version of your idea. If your idea goes against the common version, say it in the first line.&lt;br&gt;
The 30-second rule works for every screen, not only for onboarding.&lt;br&gt;
Some of its cuts were right, some I refused, some I had to undo months later. It didn't know my users, and it couldn't.&lt;/p&gt;

&lt;p&gt;Next episode: the revision loop, and the day a generated spec put my friends' live position on the map every ten seconds.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>flutter</category>
      <category>mobile</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How I made events (and their data) expire in a FlutterFlow + Firebase app</title>
      <dc:creator>Cesare Bramante</dc:creator>
      <pubDate>Thu, 01 Oct 2026 13:37:22 +0000</pubDate>
      <link>https://dev.to/cbramante/how-i-made-events-and-their-data-expire-in-a-flutterflow-firebase-app-16h9</link>
      <guid>https://dev.to/cbramante/how-i-made-events-and-their-data-expire-in-a-flutterflow-firebase-app-16h9</guid>
      <description>&lt;p&gt;Most apps are built to remember. I wanted to build one that forgets on purpose: as little as possible left in the cloud, and what's worth remembering kept only on the phone of the person who lived it.&lt;/p&gt;

&lt;p&gt;I'm an indie developer, and I build orbiTome. You drop an emoji pin on a map, pick a time, invite people from your contacts, and meet up. It's built with FlutterFlow and Firebase, and it's live on iOS and Android in five languages.&lt;/p&gt;

&lt;p&gt;One design rule shaped most of the backend: &lt;strong&gt;an event should leave the cloud shortly after it ends.&lt;/strong&gt; Plans with friends usually live in group chats, and group chats keep everything forever: where you were, when, with whom. I wanted the opposite. The map shows events, not people. When the event is over, its cloud document is deleted together with the guests' replies. Your own history (what you created, what you joined, the photos you took) stays on your phone.&lt;/p&gt;

&lt;p&gt;This post covers how that works in practice, and what I'd tell anyone trying the same thing in FlutterFlow.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. "Expires" is not one number
&lt;/h2&gt;

&lt;p&gt;My first version had one rule: delete the event two hours after it starts. It didn't survive, because events aren't all alike.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When you create it&lt;/th&gt;
&lt;th&gt;Starts&lt;/th&gt;
&lt;th&gt;Expires&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Now / In 1 hour / In 2 hours&lt;/td&gt;
&lt;td&gt;the chosen time&lt;/td&gt;
&lt;td&gt;start + 2 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tonight&lt;/td&gt;
&lt;td&gt;21:00 (if that's past, in 3 hours)&lt;/td&gt;
&lt;td&gt;23:59 the same day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tomorrow night&lt;/td&gt;
&lt;td&gt;21:00 tomorrow&lt;/td&gt;
&lt;td&gt;23:59 tomorrow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pick a date&lt;/td&gt;
&lt;td&gt;chosen date and time&lt;/td&gt;
&lt;td&gt;start + 4 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wish with a time ("anyone up for a hike Saturday at 9?")&lt;/td&gt;
&lt;td&gt;the proposed time&lt;/td&gt;
&lt;td&gt;at the proposed time, no buffer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wish, no time set&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;7 days after posting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public event from an organization&lt;/td&gt;
&lt;td&gt;set by the source&lt;/td&gt;
&lt;td&gt;end set by the source, or start + 4 h; never more than 30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On top of the table, two rules apply to every event created in the app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ceiling:&lt;/strong&gt; no event lives more than 6 hours past its start. (A Wish with no date has no start, so it isn't affected.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Floor:&lt;/strong&gt; no expiry falls less than 2 hours from now. A Wish set for 30 minutes from now still lives two hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The expiry in the table is a default, not a promise. While the event is running, the app checks the organizer's position every time the app is open on their phone, never in the background. If the organizer is within 50 meters of the pin and the expiry is less than 30 minutes away, the expiry moves to one hour from now, as many times as needed: a meetup that runs long doesn't vanish halfway through. The 6-hour ceiling still applies. It also works the other way: if the organizer checked in and then moves more than 150 meters away, the event ends there, or after a 30-minute grace period if someone else is still on site. Wishes are never extended or closed this way: they're intentions, not places.&lt;/p&gt;

&lt;p&gt;A plan for the evening that starts at 21:00 shouldn't vanish at 23:00 while people are still out. An unanswered Wish, on the other hand, is a loose proposal: it has no reason to exist past the moment it was about. If someone replies and the creator confirms, the Wish becomes a real event and its expiry is recalculated: start + 2 h.&lt;/p&gt;

&lt;p&gt;I didn't design the ceiling and the floor on a whiteboard. Each one came from a bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The floor.&lt;/strong&gt; A Wish created with "Now" had its expiry equal to its start, which was now. It was born expired and disappeared from the map without any error. When I tightened the security rules, the same creation started getting rejected. An expiry that isn't in the future is always a mistake, so now there's a two-hour minimum.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ceiling.&lt;/strong&gt; "Tonight" picked after 21:00 starts the event three hours later, after midnight, and "23:59 of the start day" became 23:59 of the following night: an event starting at 1:30 stayed alive for 22 and a half hours. The 6-hour ceiling closes that case, and other parts of the app (check-in, photos) can rely on it as a rule instead of an assumption.&lt;/p&gt;

&lt;p&gt;A third bug was about languages. The code recognized "Tonight" by comparing the button's label, and in French, Spanish and Romanian it never matched: in those languages every "Tonight" expired after 2 hours. Now the visible label, in any language, is first mapped to a stable code (&lt;code&gt;tonight&lt;/code&gt;, &lt;code&gt;tomorrow&lt;/code&gt;, &lt;code&gt;pick&lt;/code&gt;…), and expiry is computed from the code. With five languages, no logic should depend on a string the user can read.&lt;/p&gt;

&lt;p&gt;The lesson: decide expiry &lt;strong&gt;per type of plan&lt;/strong&gt;, not per database. It also changed how I write the app's copy. I no longer say something "disappears after 2 hours", because it isn't true for every event. What I can say is that an event leaves the cloud shortly after it ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Where the expiry is computed
&lt;/h2&gt;

&lt;p&gt;The expiry timestamp is computed &lt;strong&gt;when the event is created&lt;/strong&gt;, by a small FlutterFlow Custom Function, and stored in the event document as &lt;code&gt;expires_at&lt;/code&gt;. After creation, only two other places change it: turning a Wish into an event (start + 2 h) and the on-site check while the organizer has the app open (extend or close). All three respect the same 6-hour ceiling; everything else only reads the field.&lt;/p&gt;

&lt;p&gt;Computing it at creation keeps everything else simple. The map queries, the cleanup and the invite page all read one field, and none of them re-implements the rules.&lt;/p&gt;

&lt;p&gt;The trade-off is that the client computes the value. The security rules don't recompute it, but they constrain it: on create, &lt;code&gt;expires_at&lt;/code&gt; must exist, must be a timestamp, must be in the future and no more than 400 days out, and the same cap applies on update. The 6-hour ceiling lives only in the client, so a tampered client could keep its own event online for up to 400 days. It's a known limit, and I accepted it because it only affects the person doing it.&lt;/p&gt;

&lt;p&gt;The "must be in the future" check wasn't there from day one, for a specific reason: security rules also apply to the old app versions still installed on people's phones. I only turned it on once the version with the 2-hour floor was effectively the only one in use. Before that, it would have rejected events created by older versions.&lt;/p&gt;

&lt;p&gt;There's one exception. Some events are public and come from city bodies, non-profits and public institutions. They are never created from the app: they come in only through a dedicated web portal, via a private Cloud Function that writes with admin privileges and sets expiry server-side (the end given by the source, or start + 4 h, and never more than 30 days). The security rules reject any client write that tries to create an event in that category or convert an existing one. In short, the client decides expiry only for its own private events.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Who actually deletes the data
&lt;/h2&gt;

&lt;p&gt;Two mechanisms, with two different jobs.&lt;/p&gt;

&lt;p&gt;The first is a &lt;strong&gt;scheduled Cloud Function&lt;/strong&gt; that runs every 60 minutes: it looks for events whose &lt;code&gt;expires_at&lt;/code&gt; has passed and deletes them in batches. In practice an event leaves the cloud within about an hour of expiring.&lt;/p&gt;

&lt;p&gt;The second is a &lt;strong&gt;Firestore TTL policy&lt;/strong&gt; on the same field, as a safety net: if the function misses a run, Firestore deletes the documents anyway. On its own it wasn't enough for me. Google's docs say expired data is "typically deleted within 24 hours", and until then expired documents still show up in queries.&lt;/p&gt;

&lt;p&gt;That's why every query that shows events filters out those whose &lt;code&gt;expires_at&lt;/code&gt; has passed, and the client drops them again before drawing them. &lt;strong&gt;"Expired" and "deleted" are two different states:&lt;/strong&gt; the UI hides an event the moment it expires, even if the backend removes it later.&lt;/p&gt;

&lt;p&gt;What goes away with the event:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guests' replies and scheduled reminders.&lt;/strong&gt; A function triggered on the document's deletion removes them, whatever the cause: the creator deleting the event, the hourly cleanup or TTL. One cleanup point instead of three. Before it existed, replies to deleted events stayed in the database forever, because the security rules (rightly) don't let the creator delete other people's replies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The guest list&lt;/strong&gt;, which is a field of the document itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subcollections&lt;/strong&gt; (for example game data inside an event). Firestore doesn't delete them with the parent document, neither on a normal delete nor through TTL. So each of their documents carries its own expiry, slightly after the event's, with its own TTL policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files in the cloud:&lt;/strong&gt; none to delete. Event photos and videos are never uploaded (see section 4). Firebase Storage only holds profile pictures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule I took from this: for every piece of data born around an event, ask who will delete it, and when.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Keeping history without keeping it in the cloud
&lt;/h2&gt;

&lt;p&gt;Events leave the cloud, but people still want to remember them. So the events that involve you are copied into a local SQLite database on your phone, which I call the Vault.&lt;/p&gt;

&lt;p&gt;For each event the Vault keeps the title, emoji, date and time, place name and coordinates, description, organizer's name, your role and your status ("I'm here now", "I was there", "Cancelled by the organizer"). It doesn't keep the list of participants.&lt;/p&gt;

&lt;p&gt;The copy is created right away, not at expiry, because by then the cloud document may already be gone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;when you create an event or a Wish;&lt;/li&gt;
&lt;li&gt;when you reply "I'll be there";&lt;/li&gt;
&lt;li&gt;when you get an invite, but as a provisional copy: if you don't reply, it goes away after the event starts, unless you checked in or added photos. If the organizer cancels before the start, it stays marked "Cancelled by the organizer" for 7 days;&lt;/li&gt;
&lt;li&gt;when you open the app on site, check-in moves your status to "I'm here now" and then to "I was there".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why not Firestore's offline cache? Because the cache mirrors the server: when the event is deleted in the cloud, it disappears from the cache at the next sync. And the cache isn't an archive anyway, Firestore prunes it on its own. I needed the opposite: a copy that outlives the server document.&lt;/p&gt;

&lt;p&gt;The Vault can leave the phone only as an encrypted backup that the user saves wherever they want.&lt;/p&gt;

&lt;p&gt;Event photos and videos follow the same logic. They go into the app's private folder, not the gallery. Before saving, metadata is stripped, location included, and if it can't be stripped the file isn't saved. They're never uploaded, nobody else sees them, and they stay as long as the event stays in your Vault: delete it, or sign out, and they go with it. The encrypted backup doesn't include them.&lt;/p&gt;

&lt;p&gt;Three FlutterFlow + SQLite lessons that cost me hours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In SQLite queries defined in the FlutterFlow panel, parameters must be written as &lt;code&gt;${name}&lt;/code&gt;.&lt;/strong&gt; FlutterFlow doesn't pass arguments to the query, so &lt;code&gt;?&lt;/code&gt; placeholders stay empty and the &lt;code&gt;WHERE&lt;/code&gt; filters nothing: no error, just wrong results. Careful, though: &lt;code&gt;${name}&lt;/code&gt; inserts the text as-is, it's not a real bound parameter. An apostrophe in a search term breaks the query, so escape it. In Custom Action Dart code, &lt;code&gt;?&lt;/code&gt; with an argument list works normally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrations.&lt;/strong&gt; &lt;code&gt;CREATE TABLE IF NOT EXISTS&lt;/code&gt; doesn't change a table that already exists, and the database bundled with the app only applies to new installs. Users who update need &lt;code&gt;ALTER TABLE&lt;/code&gt; with nullable or defaulted columns, a check that the column doesn't already exist before adding it, and a schema version (I use &lt;code&gt;PRAGMA user_version&lt;/code&gt;) that only moves forward when the step succeeded. Users update from different versions, and a restored backup can carry an older schema than the installed app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Panel queries are for reading.&lt;/strong&gt; FlutterFlow runs the SQLite queries from the panel with &lt;code&gt;rawQuery&lt;/code&gt;, which is meant for reads: an &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt; written there fails silently. Writes belong in a Custom Action, with &lt;code&gt;rawInsert&lt;/code&gt;, &lt;code&gt;rawUpdate&lt;/code&gt; or &lt;code&gt;rawDelete&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An unplanned side effect: reading history from SQLite instead of Firestore takes the Vault's reads to zero. That matters on the free tier, and it matters more as users grow.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What happens to the invite link
&lt;/h2&gt;

&lt;p&gt;Every event has a link that works without the app: whoever opens it sees the emoji, title, organizer, place, time with a live countdown, and how many people confirmed. The page is served by a Cloud Function that asks &lt;code&gt;getJoinPreview&lt;/code&gt; for the data (an endpoint that returns a closed list of fields, one read per open) and rewrites the Open Graph meta tags. WhatsApp, Telegram and iMessage don't run JavaScript, so without that step link previews would only show generic text.&lt;/p&gt;

&lt;p&gt;Anyone with the link sees the preview, even without an account, so the link should only go to the people you want. Inside the app, the event stays visible only to the organizer's contacts or, in Ghost mode, only to the invited guests.&lt;/p&gt;

&lt;p&gt;After expiry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in the app, opening the link of an expired event shows "This event has ended." and a "Propose again" button that starts a new event from it;&lt;/li&gt;
&lt;li&gt;on the web, between expiry and deletion (about an hour at most), new previews say "This event has ended." instead of organizer and place; the title with the date stays;&lt;/li&gt;
&lt;li&gt;after deletion, &lt;code&gt;getJoinPreview&lt;/code&gt; answers that the event doesn't exist and the link shows orbiTome's generic invite page, with no event data. A CDN-cached copy can survive two minutes at most.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One last thing deserves a check in any app like this: a link preview already pasted into a group chat keeps showing title, date, organizer and place, because the chat stored the preview, not your server. You can't take back what's already been shared. You can only stop serving it, and decide from the start what goes into the preview.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What I'd tell someone starting today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Decide expiry per type of plan, write your copy to match, and don't promise a fixed number of hours.&lt;/li&gt;
&lt;li&gt;Store one expiry timestamp at creation, with a ceiling and a floor. Keep the places that can change it few, make them all respect the same ceiling, and let everything else just read it.&lt;/li&gt;
&lt;li&gt;Compute on codes, not on translated labels.&lt;/li&gt;
&lt;li&gt;Hide data on expiry, delete it later, and filter in every query. Firestore TTL is a great safety net, but if latency matters, pair it with a scheduled cleanup.&lt;/li&gt;
&lt;li&gt;For every piece of data born around the event (replies, reminders, subcollections), decide who deletes it.&lt;/li&gt;
&lt;li&gt;Tighten security rules only when the client version that respects them is the one almost everyone runs.&lt;/li&gt;
&lt;li&gt;If history matters, copy it to the device when it's born, not when it's about to disappear.&lt;/li&gt;
&lt;li&gt;Check what shared links show once the data is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building an app that forgets turned out to be harder than building one that remembers. But it changed how I explain the product: "the event ends, and so does its data" is something people get in one sentence.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building orbiTome solo, with FlutterFlow + Firebase. If you're working on something similar, I'd love to compare notes in the comments. More at &lt;a href="https://www.orbitome.com" rel="noopener noreferrer"&gt;orbitome.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>firebase</category>
      <category>mobile</category>
      <category>database</category>
    </item>
  </channel>
</rss>
