<?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: Mxolisi Masuku</title>
    <description>The latest articles on DEV Community by Mxolisi Masuku (@mxomasuku).</description>
    <link>https://dev.to/mxomasuku</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F940292%2F334ae70a-2833-4e4e-9343-df013c39bbb1.jpeg</url>
      <title>DEV Community: Mxolisi Masuku</title>
      <link>https://dev.to/mxomasuku</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mxomasuku"/>
    <language>en</language>
    <item>
      <title>Can Claude Skills Save Us From The Smartphone?</title>
      <dc:creator>Mxolisi Masuku</dc:creator>
      <pubDate>Fri, 01 May 2026 07:18:21 +0000</pubDate>
      <link>https://dev.to/mxomasuku/can-claude-skills-save-us-from-the-smartphone-4828</link>
      <guid>https://dev.to/mxomasuku/can-claude-skills-save-us-from-the-smartphone-4828</guid>
      <description>&lt;p&gt;I have grown to hate the smartphone. &lt;/p&gt;

&lt;p&gt;It's a glorious piece of tech. But it's a trap built around usefulness. &lt;/p&gt;

&lt;p&gt;At 2 AM, I wake up and reach for my phone. I know it's not healthy but I do it out of necessity. I work with people in different timezones. Overnight, decisions get made, messages arrive, deadlines shift. If I don’t check, I risk missing something that matters.&lt;/p&gt;

&lt;p&gt;So I check: Email. WhatsApp. Slack. iMessage. LinkedIn.&lt;/p&gt;

&lt;p&gt;Somewhere between those apps, the boundary breaks. I find myself no longer checking for important things but consuming whatever shows up next. By the time I stop, I’ve lost time and some tokens of my attention.&lt;/p&gt;

&lt;p&gt;This is the real problem with the smartphone:&lt;/p&gt;

&lt;p&gt;==The important messages arrive through the same channel as the distractions.==&lt;/p&gt;

&lt;p&gt;Your client’s message, your mother’s greeting, and a notification from a social media app all compete in the same space, with the same priority. Unfortunately, the apps that win are the ones engineered to interrupt, not the ones that matter.&lt;/p&gt;

&lt;p&gt;You check your phone because the environment you are operating on is designed that way. You are not addicted to your phone. Checking is the only reliable way to not miss something important. Everything else follows from that.&lt;/p&gt;

&lt;p&gt;For the first time I broke that cycle. I woke up at 2 AM and didn't touch my phone.&lt;/p&gt;

&lt;p&gt;A few days earlier, I built a Claude skill called &lt;code&gt;tomorrow&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before bed, it pulls my &lt;strong&gt;Google Calendar&lt;/strong&gt;, scans emails from the last 36 hours for anything that requires action the next day, and checks &lt;strong&gt;Slack&lt;/strong&gt; and &lt;strong&gt;iMessage&lt;/strong&gt; for messages that signal requests, deadlines, or decisions. Everything runs through a set of rules I defined.&lt;/p&gt;

&lt;p&gt;The output is a clean and prioritized rundown of the next day. It is designed to be easy to read and scan through.&lt;/p&gt;

&lt;p&gt;I run it before sleeping. When I wake up, I already know what my day is going to look like. I ran another skill &lt;code&gt;today&lt;/code&gt; just to be sure after all my morning preps.&lt;/p&gt;

&lt;p&gt;There was no reason to open all those other apps on the phone. I made coffee and started working.&lt;/p&gt;

&lt;p&gt;I solved part of the "alert fatigue" problem in my first week with Claude Skills. 1 command doing the work of 6 apps.&lt;/p&gt;

&lt;p&gt;From what I can see, we can save ourselves from mindless smartphone consumption once we start &lt;strong&gt;applying AI skills as filters and guardians for our attention&lt;/strong&gt;. This idea isn't new. It's been discussed in research and &lt;a href="https://newsletter.owainlewis.com/p/how-im-using-claude-skills-to-become" rel="noopener noreferrer"&gt;is currently being applied by other builders&lt;/a&gt; across multiple fields. This is my account of how it played out for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What skills actually change
&lt;/h2&gt;

&lt;p&gt;A Claude skill is just a folder with instructions and code. You ask Claude to do something, it loads the relevant skill, runs it, and gives you an answer. Nothing magical about the technology.&lt;/p&gt;

&lt;p&gt;What matters is the &lt;a href="https://arxiv.org/html/2405.14614" rel="noopener noreferrer"&gt;direction&lt;/a&gt; of the interaction. With a phone, information comes &lt;em&gt;to&lt;/em&gt; you and you sort it. With a skill, you go &lt;em&gt;to&lt;/em&gt; the information and it comes back already sorted. The phone is push. &lt;strong&gt;The skill is pull.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;My &lt;code&gt;tomorrow&lt;/code&gt; skill replaces the morning email, calendar, Slack, and iMessage check. I built &lt;code&gt;today&lt;/code&gt;, a midday rundown so I don’t open Slack just to see what I’ve missed. &lt;code&gt;deadline&lt;/code&gt; handles scheduling without opening Google Calendar or the reminders app. Next is a notification filter that surfaces only actionable items from LinkedIn and email and drops the rest. After that, scheduled email drafting and more complex tasks.&lt;/p&gt;

&lt;p&gt;Each skill replaces one reason to pick up the phone. &lt;/p&gt;

&lt;p&gt;However, I still need the phone for music, messaging and for the camera. But the involuntary uses, the checks, the just-in-cases will start to fall away. Picking up the phone will eventually become a deliberate act. Something I do for pleasure because the important things have already been handled.&lt;/p&gt;

&lt;p&gt;The real shift here isn't necessarily killing the smartphone but cutting the relationship where it owns your attention by default and you fight to get it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is different from "just use less screen time or use different notifications."
&lt;/h2&gt;

&lt;p&gt;I've tried screen-time apps. Greyscale mode. App blockers etc.&lt;/p&gt;

&lt;p&gt;They all fail because they treat the symptom which is checking your phone frequently. &lt;strong&gt;You check apps on your phone because it's the only way to not miss things.&lt;/strong&gt; The impulse to open and check the phone will go away the moment we remove the uncertainty around the alerts and notifications they bring. Good systems make the right behaviour cheap and the wrong behaviour expensive. &lt;/p&gt;

&lt;h2&gt;
  
  
  ...But you still need your phone don't you? ...and other gotchas
&lt;/h2&gt;

&lt;p&gt;Yes. &lt;/p&gt;

&lt;p&gt;Skills run on a phone too. &lt;/p&gt;

&lt;p&gt;I can trigger &lt;code&gt;tomorrow&lt;/code&gt; and &lt;code&gt;deadline&lt;/code&gt; skills from the Claude app on my Android. I'm not escaping the phone. I'm changing what I do on it. There is the &lt;strong&gt;phone-as-tool&lt;/strong&gt; and the &lt;strong&gt;phone-as-an-attention-sinkhole&lt;/strong&gt; . These are two different things that share the same hardware. &lt;/p&gt;

&lt;p&gt;Skills are limited because they only work if you build them with the right context for your needs. Most people won't. The barrier is somewhere between users who know what a markdown file is and users who can write basic instructions. There is no coding involved for entry level skills, but a barrier does exist. Until that barrier drops, skills will be a tool for people already inclined to build their way out of problems.&lt;/p&gt;

&lt;p&gt;Secondly, the model could change. APIs break. Permissions change. Anthropic could deprecate skills. The economics on AI could shift (tokens are already getting expensive). I'm not betting on this specific implementation. &lt;/p&gt;

&lt;p&gt;Another flaw is the issue latency vs urgency tradeoffs. If you decide to get rid of real time notifications for a sweeping 3 hour skill or agent run, there is a possibility of seeing a notification 2 hours too late.&lt;/p&gt;

&lt;p&gt;I'm betting on the pattern of intentional pull-based AI tools that replace specific reasons to grab a phone. If Claude skills aren't the thing that wins, something shaped like them will.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Future That IBM Saw in 2020:
&lt;/h2&gt;

&lt;p&gt;We are not yet at the point of deleting apps from our smartphones for good. But if one skill can replace the need to open 6 apps then half the battle is one. History has taught us that convenient tech always wins! Maybe the app can be replaced once trust issues around AI are addressed. &lt;/p&gt;

&lt;p&gt;In 2020, researchers at &lt;strong&gt;IBM&lt;/strong&gt; published a paper proposing exactly this kind of system: &lt;a href="https://arxiv.org/pdf/2003.02097" rel="noopener noreferrer"&gt;an intelligent notification framework that sits between you and your apps&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;It learns which alerts to issue, which to suppress, and which to batch together for later. Their framework classified alerts by severity, modelled user behaviour, preferences and availability. They called it a "snooze-less" system. The goal was to eliminate the need to snooze or ignore notifications entirely. If the system does its job, every notification you see is one that deserves your attention at that moment.&lt;/p&gt;

&lt;p&gt;The paper was ahead of its time. In 2020 the infrastructure to build this didn't exist for individuals. You needed enterprise backends, custom ML pipelines, dedicated integration work. In 2026, a Claude skill connected to Gmail and Calendar via MCP does roughly the same thing from a markdown file on your laptop. The gap between research prototype and personal tool collapsed in six years. There is further research being done on &lt;a href="https://www.courier.com/blog/your-notifications-now-have-two-audiences-humans-and-ai-agents" rel="noopener noreferrer"&gt;why companies should now build notifications for both users and AI Agents&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The IBM paper also hints at a future that goes further than filtering.&lt;/p&gt;

&lt;p&gt;If an AI agent can reliably read, classify, and act on your notifications, you don’t need the app at all. &lt;strong&gt;You need the protocol:&lt;/strong&gt; the underlying rules and interfaces that define how messages are sent, received, and interpreted.&lt;/p&gt;

&lt;p&gt;Email already works this way. Slack exposes this through its API. WhatsApp is a bit tricky, and the security model there makes direct access harder, but that’s a separate problem. The point is: &lt;strong&gt;the app was always just a frontend for a communication channel.&lt;/strong&gt; If your AI agent can interface with that channel directly, the app becomes optional. Not today. But the architecture is already pointing there.&lt;/p&gt;

&lt;p&gt;That's the future I find most compelling is where AI replaces the entire app layer to talk directly to the systems underneath. We will stop interacting with interfaces designed to capture our attention and start interacting with an agent designed to protect it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm actually claiming
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The notification-driven model of computing is a historical accident, not a permanent condition.&lt;/strong&gt; It won because attention was the cheapest thing to monetise on a small screen with constant connectivity. It will lose to anything that lets us get signal without noise.&lt;/p&gt;

&lt;p&gt;Maybe Claude skills are an early primitive version of that anything. They're not pretty. They require setup. The ecosystem is young. There is a lot of push-and-pull. But they're the first tool I've used that actually changes the math of why I pick up my phone. &lt;/p&gt;

&lt;h3&gt;
  
  
  With the right tools this is how App Independence can be attained:
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Step one:&lt;/strong&gt; Build a scheduled digest skill that pulls Gmail and Slack, iMessage every few hours and filters for what actually needs me. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step two:&lt;/strong&gt; Turn off notifications from those apps on my phone. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step three:&lt;/strong&gt; Live with that for two weeks and see what falls through. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step four:&lt;/strong&gt; If nothing falls through, stop opening the apps entirely. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step five:&lt;/strong&gt; Maybe a month out: delete them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Thanks for reading
&lt;/h2&gt;

&lt;p&gt;All Claude skills referenced in this article are published and maintained &lt;a href="https://github.com/mxomasuku/masuku-claude-skills.git" rel="noopener noreferrer"&gt;here&lt;/a&gt;:&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Firestore Architecture For Scaling Read-Intensive Multi-Tenant Software</title>
      <dc:creator>Mxolisi Masuku</dc:creator>
      <pubDate>Sat, 25 Apr 2026 21:22:57 +0000</pubDate>
      <link>https://dev.to/mxomasuku/firestore-architecture-for-scaling-read-intensive-multi-tenant-software-3epj</link>
      <guid>https://dev.to/mxomasuku/firestore-architecture-for-scaling-read-intensive-multi-tenant-software-3epj</guid>
      <description>&lt;p&gt;When you research how to build a multi-tenant SaaS on Firestore, most articles stop at one idea: &lt;em&gt;put a tenantId field on every document&lt;/em&gt;. That's true, but it's the easy half. The more tricky question is how do you arrange your collections so that reads, writes, and costs stay within budget as the business scales.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background
&lt;/h2&gt;

&lt;p&gt;I was afraid of creating a system that was more trouble and expensive than it was worth.&lt;/p&gt;

&lt;p&gt;I built a multi-tenant SaaS &lt;a href="https://trogern-logistics.web.app/" rel="noopener noreferrer"&gt;Trogern&lt;/a&gt; for managing informal taxi businesses in Zimbabwe. I was the first tenant. The industry here is so volatile and unpredictable that instant record retrieval and logging is key for business operations. In the process I got obsessed with designing a database that wouldn't generate reads and writes which I couldn't manage.&lt;/p&gt;

&lt;p&gt;For most people, &lt;strong&gt;this is not a problem you need to solve today.&lt;/strong&gt; Firestore is dirt cheap. The free tier gives you &lt;a href="https://airbyte.com/data-engineering-resources/google-firestore-pricing" rel="noopener noreferrer"&gt;50,000 reads and 20,000 writes per day&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two axes of scale on Firestore
&lt;/h2&gt;

&lt;p&gt;Multi-tenant load doesn't scale along one line, it scales along two. &lt;strong&gt;Deep&lt;/strong&gt; and &lt;strong&gt;Wide&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep scale.&lt;/strong&gt; One tenant with a lot of data. This can be something like one taxi company, 100 vehicles, 100 drivers, 20,000 income logs per quarter. The stress falls on one partition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wide scale.&lt;/strong&gt; Many tenants with small amounts of data. 1,000 companies, each with 10 vehicles, all opening their dashboards at 9am Monday morning. The stress falls on global indexes, listeners, and cold-start reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep scaling&lt;/strong&gt; breaks different things than &lt;strong&gt;wide scaling.&lt;/strong&gt; The same data model can be great at one and awful at the other. For this reason you will find that most documentation, including Firebase official docs, do not recommend a one-size-fits all best practice for Firestore database design.&lt;/p&gt;

&lt;p&gt;I'm using &lt;em&gt;deep&lt;/em&gt; and &lt;em&gt;wide&lt;/em&gt; for the rest of the article because &lt;em&gt;vertical&lt;/em&gt; and &lt;em&gt;horizontal&lt;/em&gt; are already taken by infrastructure vocabulary and they mean roughly the opposite of what you'd guess here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What flat collections look like
&lt;/h2&gt;

&lt;p&gt;Firestore supports subcollections — collections nested inside a document. For a fleet app you could do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;companies/{companyId}/
  vehicles/{vehicleId}/
    incomeLogs/{logId}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A flat layout goes the other way. One top-level collection per business entity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;companies/{companyId}
vehicles/{vehicleId}       // field: companyId
drivers/{driverId}         // field: companyId
income/{logId}             // fields: companyId, vehicleId, driverId
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each document carries &lt;code&gt;companyId&lt;/code&gt; as a &lt;strong&gt;partition field&lt;/strong&gt; or shared key. The document id is the primary key, usually auto-generated or a natural one like a plate number. The document also carries the other foreign keys it'll be queried against — &lt;code&gt;vehicleId&lt;/code&gt;, &lt;code&gt;driverId&lt;/code&gt; . This is to ensure that one query can pull income for a specific driver in a specific vehicle at a specific company.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case for flat collections
&lt;/h2&gt;

&lt;p&gt;Note: &lt;strong&gt;Firestore charges per document read, not per path depth.&lt;/strong&gt; A nested lookup for one vehicle's income is one query, and it's just as cheap as a flat one.&lt;/p&gt;

&lt;p&gt;The real reasons to go flat in a multi-tenant app are these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cross-tenant admin queries.&lt;/strong&gt; When you have a multi-tenant system, you become a platform manager and you will need to read across tenants. In a nested layout that requires &lt;a href="https://firebase.blog/posts/2019/06/understanding-collection-group-queries/" rel="noopener noreferrer"&gt;a collection group query.&lt;/a&gt;. They are a Firestore query that operates across every subcollection of the same name. Collection groups work, but they demand more composite indexes and security-rule semantics. I wanted to avoid them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Simpler security rules.&lt;/strong&gt; All you have to do when checking a company out is "match on the &lt;code&gt;companyId&lt;/code&gt; field" which covers every collection. A nested layout makes you write path-matching rules that are more complex and easier to get wrong.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Listener scoping.&lt;/strong&gt; Now we look at &lt;a href="https://firebase.google.com/docs/firestore/query-data/listen" rel="noopener noreferrer"&gt;&lt;code&gt;onSnapshot&lt;/code&gt;&lt;/a&gt;, Firestore's real-time listener. It is a live socket that bills &lt;a href="https://firebase.google.com/docs/firestore/pricing#listens" rel="noopener noreferrer"&gt;one read per doc that flows through it&lt;/a&gt;. On a flat collection that's one query: &lt;code&gt;collection("income").where("companyId", "==", X).where("driverId", "==", Y).onSnapshot(...)&lt;/code&gt;. One socket, one cleanup, identical shape no matter what you're watching.&lt;br&gt;
On subcollections the path &lt;em&gt;is&lt;/em&gt; the scope. Watching one driver across 100 vehicles means either a &lt;a href="https://firebase.google.com/docs/firestore/query-data/queries#collection-group-query" rel="noopener noreferrer"&gt;collection group query&lt;/a&gt; (new index, new rule path, you've given back the benefit you went nested for) or 100 parallel listeners merged client-side. A real dashboard opens four or five of these and flat collections keep every one uniform. Nested collections however, give you a different path depth per concern, plus deduplication logic and 100× the cleanup callbacks. That's where the bill blows up. A single leaked listener in flat collections is one socket pulling extra reads, but a leaked listener over many subcollections could be dozens of sockets which all silent until the bill comes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Aggregation queries.&lt;/strong&gt; &lt;a href="https://firebase.google.com/docs/firestore/query-data/aggregation-queries" rel="noopener noreferrer"&gt;&lt;code&gt;count&lt;/code&gt;, &lt;code&gt;sum&lt;/code&gt;, and &lt;code&gt;avg&lt;/code&gt;&lt;/a&gt; are priced per 1,000 index entries (1 read per 1,000). They run cleanly on a flat collection. They work on subcollections too, but you run them per parent. This is fine for one tenant, painful for a platform-wide report.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Data migration and correction.&lt;/strong&gt; You can fix a bug like a wrong &lt;code&gt;driverName&lt;/code&gt; on a week of income logs or a missing field across half the catalog with one query &lt;code&gt;where("companyId", "==", X).where("cashDate", "&amp;gt;=", date)&lt;/code&gt; in a flat system. In a nested layout that same fix is a recursive walk down every company → vehicle → income path. This happens way more often than the architecture diagrams imply.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The one-query demonstration
&lt;/h2&gt;

&lt;p&gt;With a flat layout, pulling income for a driver in a vehicle for a company is one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;income&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;companyId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;==&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trogern-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vehicleId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;==&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ADB-121-GP&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;driverId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;==&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DRV-8421&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cashDate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fine. That's cheap for one request. Now 1,000 tenants do it at the same time. Now each of those 1,000 tenants has a dashboard that runs three queries like this on page load and opens a real-time listener on notifications. Now half of them have 100 vehicles each, not 10.&lt;/p&gt;

&lt;p&gt;That's when things get interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to read: per-log vs rollup
&lt;/h2&gt;

&lt;p&gt;Once a flat layout is in place, the next decision is &lt;em&gt;how much work each dashboard load does&lt;/em&gt;. There are two patterns. The simulator below toggles between them, so it's worth defining them now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-log.&lt;/strong&gt; The obvious one. Every dashboard load runs a query against &lt;code&gt;income&lt;/code&gt; and reads back every raw log in the window. 90-day quarterly view, 200 logs/day → 18,000 reads per load, per user. Simple, always correct, no extra moving parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily rollup.&lt;/strong&gt; Every write to &lt;code&gt;income&lt;/code&gt; also writes (or increments) a document in &lt;code&gt;dailyRollups/{companyId}_{date}&lt;/code&gt; with the summed amount and a count. The dashboard reads 90 of those instead of thousands of raw logs. A Cloud Function fans the writes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Aside: Cloud Functions aren't free either.&lt;/strong&gt; Every income log triggers one invocation, and 2nd-gen functions bill at &lt;a href="https://cloud.google.com/functions/pricing" rel="noopener noreferrer"&gt;$0.40 per million invocations&lt;/a&gt; past the 2M free tier, plus compute time (GB-seconds and GHz-seconds) and any egress. For an app which does 200 logs/day across a big tenant, or 20/day across 1,000 small ones. That's roughly 6M invocations/month at the top end, around &lt;strong&gt;$1.60 in invocation fees plus a few dollars of compute&lt;/strong&gt;. The rollup still wins by orders of magnitude on the read side, but the trade isn't quite "two docs written for free." Watch the function bill if your write volume is high — it's the next thing that bites once you've fixed reads.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dailyRollups/
  trogern-01_2026-04-25
    { companyId, date, totalIncome, totalExpense, logCount, ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You pay a little more on the write path (two docs per income log instead of one) and you save 20× to 200× on reads. For a read-heavy dashboard app, that trade is one-sided.&lt;/p&gt;

&lt;p&gt;The downside: any historical correction requires recomputing the rollup. That's a Cloud Function with a retry queue, not a one-liner. It's the kind of debt you have to decide to take.&lt;/p&gt;

&lt;h2&gt;
  
  
  The simulator
&lt;/h2&gt;

&lt;p&gt;I built this initially with my defaults, but every input is editable. Move the tenant slider, flip the architecture, watch the cost.&lt;/p&gt;

&lt;p&gt;&lt;a href="/firestore-cost-simulator" rel="noopener"&gt;&lt;br&gt;
  Interactive&lt;br&gt;
  Open the Firestore Cost Simulator →&lt;br&gt;
  Plug in your tenant profile and compare per-log vs rollup architecture costs.&lt;br&gt;
&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The math is: each dashboard load fans out into a handful of reads. There is one big query for the income window, plus the surrounding lists, notifications and security-rule lookups. Multiplied by users, by loads per day, by 30 days, by tenants. Priced at $0.06 per 100K reads.&lt;/p&gt;

&lt;p&gt;A dashboard load fans out roughly like this (numbers lifted from my actual controller code):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Reads per load (per-log)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Income, 90-day period stats&lt;/td&gt;
&lt;td&gt;&lt;code&gt;logsPerDay × 90&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drivers list&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vehicles&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vehicles list&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vehicles&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active target&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notifications initial fetch&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security-rule &lt;code&gt;get(/users/{uid})&lt;/code&gt; per protected query&lt;/td&gt;
&lt;td&gt;~6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a small tenant (10 vehicles, 20 logs/day) the per-log total is &lt;strong&gt;1,877 reads per load&lt;/strong&gt;. For a big tenant (100 vehicles, 200 logs/day) it's &lt;strong&gt;18,257&lt;/strong&gt;. The income window dominates — everything else is rounding error.&lt;/p&gt;

&lt;p&gt;Here's the underlying function the widget runs, in about 25 lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;vehicles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;logsPerDay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;loadsPerUserPerDay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Arch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;per-log&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rollup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;READ_PRICE_PER_100K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// USD, North America&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readsPerLoad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Arch&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Rollup stores one doc per company per day, so 90 docs for a quarterly dashboard.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;income&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;arch&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;per-log&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logsPerDay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lists&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vehicles&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// drivers list + vehicles list&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;misc&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                &lt;span class="c1"&gt;// target + notifications + rule reads&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;income&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;lists&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;misc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;monthlyUSD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Arch&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loadsPerDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loadsPerUserPerDay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;monthlyReads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readsPerLoad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;loadsPerDay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monthlyReads&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;READ_PRICE_PER_100K&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Wide scale (all small tenants):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tenants&lt;/th&gt;
&lt;th&gt;Per-log $/month&lt;/th&gt;
&lt;th&gt;With daily rollups&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;$1.01&lt;/td&gt;
&lt;td&gt;$0.09&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;$101&lt;/td&gt;
&lt;td&gt;$9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;$1,013&lt;/td&gt;
&lt;td&gt;$90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;$10,135&lt;/td&gt;
&lt;td&gt;$902&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Deep scale: One big tenant (100 vehicles, one company): per-log = &lt;strong&gt;$24.65/month&lt;/strong&gt;, rollup = &lt;strong&gt;$0.47/month&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two things fall out.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The per-log model's bill crosses $1,000/month somewhere around 1,000 small tenants. That's fine if you're charging $30/month per seat. That's catastrophic if you're running freemium.&lt;/li&gt;
&lt;li&gt;The per-log model costs more for &lt;em&gt;one&lt;/em&gt; big tenant than for a hundred small ones. Deep scale punishes you harder than wide scale, because the income-log window grows linearly with the fleet while everything else stays flat.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Reality Checks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Under ~200 small tenants, or your first big tenant:&lt;/strong&gt; stay per-log. The complexity of rollups isn't worth the $20–$100/month you'd save. Spend that time on the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Planning for 1,000+ tenants, or a first tenant with 50+ vehicles:&lt;/strong&gt; build the rollup pattern on day one. Migrating from per-log to rollup once you have live data is painful. It is much cheaper to set up the Cloud Function up front and write both documents from the first income log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Somewhere in the middle:&lt;/strong&gt; watch your bill. Firestore bills daily, the spike shows up fast once it starts.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>firebase</category>
      <category>databasedesign</category>
      <category>architecture</category>
      <category>multitenantsaas</category>
    </item>
    <item>
      <title>Build Reliable Local Notifications in Flutter (Step-by-Step)</title>
      <dc:creator>Mxolisi Masuku</dc:creator>
      <pubDate>Thu, 16 Apr 2026 08:43:51 +0000</pubDate>
      <link>https://dev.to/mxomasuku/build-reliable-local-notifications-in-flutter-step-by-step-3l1f</link>
      <guid>https://dev.to/mxomasuku/build-reliable-local-notifications-in-flutter-step-by-step-3l1f</guid>
      <description>&lt;p&gt;&lt;em&gt;Companion post to &lt;a href="https://www.mxomasuku.com/blog/how-i-built-the-long-game-notification-system-a-journey-into-notifications-and-behavioral-engineering" rel="noopener noreferrer"&gt;How I Built the Long Game Notification System&lt;/a&gt;. This is the walkthrough. If you want the thinking behind the decisions, read that first. If you just want the recipe, you're in the right place.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What You're Building
&lt;/h2&gt;

&lt;p&gt;A notification system where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Notifications fire at the &lt;strong&gt;exact time&lt;/strong&gt; you set, every day&lt;/li&gt;
&lt;li&gt;They work when the phone is &lt;strong&gt;locked&lt;/strong&gt;, the app is &lt;strong&gt;killed&lt;/strong&gt;, or the device is in &lt;strong&gt;Doze mode&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Notification content is &lt;strong&gt;personalized&lt;/strong&gt; with fresh data on every app open&lt;/li&gt;
&lt;li&gt;The entire system runs &lt;strong&gt;locally&lt;/strong&gt; — no push server, no Cloud Functions, zero delivery cost&lt;/li&gt;
&lt;li&gt;Pomodoro-style one-shot alarms fire on time even when the app is suspended&lt;/li&gt;
&lt;li&gt;Notifications &lt;strong&gt;survive Samsung, Xiaomi, and Huawei battery killers&lt;/strong&gt; without FCM&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What You're NOT Using
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;workmanager&lt;/code&gt; — the OS throttles background tasks. Your 15-minute poll becomes hours.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Dart Timer.periodic&lt;/code&gt; for delivery — dies the moment the phone locks.&lt;/li&gt;
&lt;li&gt;Firebase Cloud Messaging — overkill for local, user-specific scheduling.&lt;/li&gt;
&lt;li&gt;Background Fetch (iOS) — Apple gives you 0–2 executions per day if you're lucky.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: Dependencies
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pubspec.yaml&lt;/span&gt;
&lt;span class="na"&gt;dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;flutter_local_notifications&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^18.0.1&lt;/span&gt;
  &lt;span class="na"&gt;timezone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^0.10.0&lt;/span&gt;
  &lt;span class="na"&gt;flutter_timezone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^3.0.1&lt;/span&gt;
  &lt;span class="na"&gt;permission_handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^11.3.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;The notification plugin handles scheduling. &lt;/li&gt;
&lt;li&gt;The timezone packages ensure your 8:00 AM means 8:00 AM in Johannesburg, not UTC. &lt;/li&gt;
&lt;li&gt;The permission handler lets you request notification access on Android 13+ &lt;strong&gt;and&lt;/strong&gt; battery optimization exemption on Samsung/OEM devices.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 2: Android Permissions
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- android/app/src/main/AndroidManifest.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.POST_NOTIFICATIONS"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.RECEIVE_BOOT_COMPLETED"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.SCHEDULE_EXACT_ALARM"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.USE_EXACT_ALARM"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.WAKE_LOCK"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each one does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Required on Android 13+ to show any notification at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RECEIVE_BOOT_COMPLETED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Alarms survive device reboots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fires at the exact second, even in Doze mode (API ≤ 33)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;USE_EXACT_ALARM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Guaranteed exact alarms on API 33+ — no user prompt needed, unlike &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; on API 34+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REQUEST_IGNORE_BATTERY_OPTIMIZATIONS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lets you show the system "Allow unrestricted battery?" dialog — critical for Samsung, Xiaomi, Huawei&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WAKE_LOCK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keeps the CPU alive long enough to process the alarm and fire the notification&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Watch out:&lt;/strong&gt; On Android 14+ (API 34), &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; isn't granted by default. &lt;code&gt;USE_EXACT_ALARM&lt;/code&gt; is a stronger alternative that's always granted for apps that declare it — but it may trigger Google Play review. Having both ensures maximum compatibility. Your code still needs the inexact fallback we'll cover in Step 7.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Initialize on App Start
&lt;/h2&gt;

&lt;p&gt;Before you can schedule anything, you need to initialize the timezone database and the notification plugin. This runs once in &lt;code&gt;main()&lt;/code&gt;, before &lt;code&gt;runApp()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

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

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;NotificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;init&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;runApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MyApp&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// notification_service.dart&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter_local_notifications/flutter_local_notifications.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:timezone/timezone.dart'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:timezone/data/latest_all.dart'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tzdata&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter_timezone/flutter_timezone.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:permission_handler/permission_handler.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;FlutterLocalNotificationsPlugin&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="n"&gt;FlutterLocalNotificationsPlugin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&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="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Initialize the timezone database and detect the device's zone.&lt;/span&gt;
    &lt;span class="c1"&gt;//    Without this, all scheduled times are wrong.&lt;/span&gt;
    &lt;span class="n"&gt;tzdata&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initializeTimeZones&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;tzName&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;FlutterTimezone&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLocalTimezone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setLocalLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tzName&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Fallback: stays UTC if detection fails&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Initialize the plugin.&lt;/span&gt;
    &lt;span class="c1"&gt;//    We don't request permissions here — that happens later,&lt;/span&gt;
    &lt;span class="c1"&gt;//    at a moment that makes sense in your UX flow.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;androidSettings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AndroidInitializationSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s"&gt;'@mipmap/ic_launcher'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;iosSettings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DarwinInitializationSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;requestAlertPermission:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;requestBadgePermission:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;requestSoundPermission:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;InitializationSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;android:&lt;/span&gt; &lt;span class="n"&gt;androidSettings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;iOS:&lt;/span&gt; &lt;span class="n"&gt;iosSettings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nl"&gt;onDidReceiveNotificationResponse:&lt;/span&gt; &lt;span class="n"&gt;_onNotificationTap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;onDidReceiveBackgroundNotificationResponse:&lt;/span&gt; &lt;span class="n"&gt;_onBackgroundTap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Handle taps when the app is alive&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;_onNotificationTap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NotificationResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Navigate to the relevant screen, stop a timer, etc.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Handle taps when the app was killed — must be top-level or static&lt;/span&gt;
  &lt;span class="nd"&gt;@pragma&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'vm:entry-point'&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;void&lt;/span&gt; &lt;span class="n"&gt;_onBackgroundTap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NotificationResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// App relaunches — handle in your normal init flow&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;strong&gt;Why timezone matters:&lt;/strong&gt; If you schedule for "8:00 AM" without initializing timezones, the plugin may interpret that as UTC. In Johannesburg (UTC+2), that's 10:00 AM. In New York (UTC-5), that's 3:00 AM. Always initialize before any &lt;code&gt;zonedSchedule&lt;/code&gt; call.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Schedule a Daily Repeating Notification
&lt;/h2&gt;

&lt;p&gt;This is the core of the entire system. Three lines do the heavy lifting.&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="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;scheduleDailyNotification&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                           &lt;span class="c1"&gt;// Fixed ID per notification type&lt;/span&gt;
    &lt;span class="n"&gt;title&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="n"&gt;_nextInstanceOfTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;            &lt;span class="c1"&gt;// Next occurrence of this time&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NotificationDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;android:&lt;/span&gt; &lt;span class="n"&gt;AndroidNotificationDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;'your_channel_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;'Your Channel Name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;channelDescription:&lt;/span&gt; &lt;span class="s"&gt;'What this channel is for'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;importance:&lt;/span&gt; &lt;span class="n"&gt;Importance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;high&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;priority:&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;high&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nl"&gt;iOS:&lt;/span&gt; &lt;span class="n"&gt;DarwinNotificationDetails&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nl"&gt;uiLocalNotificationDateInterpretation:&lt;/span&gt;
        &lt;span class="n"&gt;UILocalNotificationDateInterpretation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;absoluteTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;androidScheduleMode:&lt;/span&gt; &lt;span class="n"&gt;AndroidScheduleMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exactAllowWhileIdle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;matchDateTimeComponents:&lt;/span&gt; &lt;span class="n"&gt;DateTimeComponents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// ← THIS IS THE KEY&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 three things that make it work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;exactAllowWhileIdle&lt;/code&gt;&lt;/strong&gt; — fires even in Doze mode. The OS wakes up just enough to deliver your notification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;matchDateTimeComponents: DateTimeComponents.time&lt;/code&gt;&lt;/strong&gt; — tells the OS: "repeat this every day at this hour:minute." You schedule it once. It fires every day. No background task. No polling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_nextInstanceOfTime&lt;/code&gt;&lt;/strong&gt; — computes the next future occurrence of the target time.
&lt;/li&gt;
&lt;/ol&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;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TZDateTime&lt;/span&gt; &lt;span class="nf"&gt;_nextInstanceOfTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;minute&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TZDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;local&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;scheduled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TZDateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// zonedSchedule requires a future datetime&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;scheduled&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;scheduled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scheduled&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;days:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;scheduled&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;strong&gt;Why this helper exists:&lt;/strong&gt; &lt;code&gt;zonedSchedule&lt;/code&gt; requires the initial fire time to be in the future. If it's 9:00 AM and you schedule for 8:00 AM, the helper pushes it to 8:00 AM tomorrow. The &lt;code&gt;matchDateTimeComponents&lt;/code&gt; flag handles every day after that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: CRITICAL: The Cancel Trap
&lt;/h2&gt;

&lt;p&gt;==(This bit is annoying if not taken care of)==&lt;/p&gt;

&lt;p&gt;This is the bug that will silently break your notifications and you won't notice for days. I shipped it. It cost me a full day of missed notifications before I caught it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The broken pattern
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;//  DO NOT DO THIS&lt;/span&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="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;reschedule&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                     &lt;span class="c1"&gt;// Destroys the repeating alarm&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;         &lt;span class="c1"&gt;// Creates a new one&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks correct. It's not. Here's what happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User opens the app at &lt;strong&gt;9:00 AM&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cancel(1)&lt;/code&gt; — destroys the existing repeating 8:00 AM alarm&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_nextInstanceOfTime(8, 0)&lt;/code&gt; — 8:00 AM today already passed, returns &lt;strong&gt;tomorrow 8:00 AM&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;zonedSchedule(1, ...)&lt;/code&gt; — schedules a new alarm starting tomorrow&lt;/li&gt;
&lt;li&gt;Tomorrow, user opens the app at 9:00 AM again → &lt;strong&gt;same thing&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The notification is perpetually pushed to "tomorrow" and &lt;strong&gt;never fires&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The correct pattern
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only cancel when the user disables the notification&lt;/span&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="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// User turned it off — remove the alarm&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// Calling zonedSchedule with the same ID REPLACES the existing alarm&lt;/span&gt;
  &lt;span class="c1"&gt;// without resetting the repeat cycle. No cancel needed.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;strong&gt;The rule:&lt;/strong&gt; &lt;code&gt;zonedSchedule&lt;/code&gt; with the same ID overwrites the previous alarm — it updates the title, body, and schedule time without destroying the repeat. You only need &lt;code&gt;cancel&lt;/code&gt; when you want the notification to stop entirely.&lt;/p&gt;

&lt;p&gt;This means you can safely call &lt;code&gt;rescheduleAll()&lt;/code&gt; on every app open to refresh notification content (e.g., "You have 3 projects today" becomes "You have 4 projects today") without breaking delivery.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Refresh Content on App Open
&lt;/h2&gt;

&lt;p&gt;Notification bodies are frozen at schedule time. If the user adds a project at 11 PM, you want tomorrow's morning notification to include it.&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;// main.dart — after init, before runApp&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;FirebaseAuth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentUser&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;NotificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rescheduleAllNotifications&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;catchError&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;rescheduleAllNotifications()&lt;/code&gt; fetches fresh data and calls &lt;code&gt;zonedSchedule&lt;/code&gt; for each enabled notification, overwriting the stale body. No &lt;code&gt;cancel&lt;/code&gt; — just overwrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two things to guard against:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Auth check&lt;/strong&gt; — if your notification content depends on user-specific data (Firestore queries), and no user is signed in, the query will hang. This blocks &lt;code&gt;main()&lt;/code&gt; and your app never starts. Gate it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fire-and-forget&lt;/strong&gt; — use &lt;code&gt;.catchError((_) {})&lt;/code&gt;. If the reschedule fails (offline, Firestore timeout), the previously scheduled alarm still fires with yesterday's content. That's better than crashing on startup.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 7: Handle Android 14+ Exact Alarm Restriction
&lt;/h2&gt;

&lt;p&gt;Android 14 changed the rules. &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; is no longer auto-granted. If your app calls &lt;code&gt;exactAllowWhileIdle&lt;/code&gt; without the permission, it throws.&lt;/p&gt;

&lt;p&gt;The graceful fallback:&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="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;scheduleDailyNotification&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;scheduledTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_nextInstanceOfTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&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;details&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* your NotificationDetails */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Try exact first — best experience&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&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="n"&gt;scheduledTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;uiLocalNotificationDateInterpretation:&lt;/span&gt;
          &lt;span class="n"&gt;UILocalNotificationDateInterpretation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;absoluteTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;androidScheduleMode:&lt;/span&gt; &lt;span class="n"&gt;AndroidScheduleMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exactAllowWhileIdle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;matchDateTimeComponents:&lt;/span&gt; &lt;span class="n"&gt;DateTimeComponents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Exact alarm not permitted — fall back to inexact&lt;/span&gt;
    &lt;span class="c1"&gt;// Delivery may drift by ~10 minutes, but the notification still fires&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&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="n"&gt;scheduledTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;uiLocalNotificationDateInterpretation:&lt;/span&gt;
          &lt;span class="n"&gt;UILocalNotificationDateInterpretation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;absoluteTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;androidScheduleMode:&lt;/span&gt; &lt;span class="n"&gt;AndroidScheduleMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inexactAllowWhileIdle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;matchDateTimeComponents:&lt;/span&gt; &lt;span class="n"&gt;DateTimeComponents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;time&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;For most notifications, a 10-minute window is fine. "Your day is 63% gone" at 2:10 PM instead of 2:00 PM doesn't break the experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apply this same pattern to one-shot alarms too.&lt;/strong&gt; Your pomodoro alarm scheduler should have the same try/catch:&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="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;schedulePhaseAlarm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;durationMinutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="n"&gt;NotificationDetails&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&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;fireAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TZDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;minutes:&lt;/span&gt; &lt;span class="n"&gt;durationMinutes&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;alarmId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&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;fireAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;uiLocalNotificationDateInterpretation:&lt;/span&gt;
          &lt;span class="n"&gt;UILocalNotificationDateInterpretation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;absoluteTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;androidScheduleMode:&lt;/span&gt; &lt;span class="n"&gt;AndroidScheduleMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exactAllowWhileIdle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fall back to inexact — still fires, may drift ~5-10 min&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;alarmId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&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;fireAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;uiLocalNotificationDateInterpretation:&lt;/span&gt;
            &lt;span class="n"&gt;UILocalNotificationDateInterpretation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;absoluteTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;androidScheduleMode:&lt;/span&gt; &lt;span class="n"&gt;AndroidScheduleMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inexactAllowWhileIdle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'[Notifications] Failed to schedule alarm: &lt;/span&gt;&lt;span class="si"&gt;$e&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 8: One-Shot Alarms (Pomodoro, Timers)
&lt;/h2&gt;

&lt;p&gt;Daily repeating alarms cover most cases. But sometimes you need a notification that fires once at a specific future time — like when a 25-minute pomodoro focus session ends.&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="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;scheduleOneShot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;minutesFromNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="n"&gt;NotificationDetails&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&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;fireAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TZDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;minutes:&lt;/span&gt; &lt;span class="n"&gt;minutesFromNow&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;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;title&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="n"&gt;fireAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;uiLocalNotificationDateInterpretation:&lt;/span&gt;
        &lt;span class="n"&gt;UILocalNotificationDateInterpretation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;absoluteTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;androidScheduleMode:&lt;/span&gt; &lt;span class="n"&gt;AndroidScheduleMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exactAllowWhileIdle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// NO matchDateTimeComponents — this fires once, doesn't repeat&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;strong&gt;Why you need this for pomodoro:&lt;/strong&gt; A Dart &lt;code&gt;Timer.periodic&lt;/code&gt; runs in your app's process. When the user locks their phone, the OS suspends the app within ~30 seconds. Your timer stops ticking. The phase ends, and — silence. The bell only rings when they unlock the phone and the app resumes.&lt;/p&gt;

&lt;p&gt;An OS alarm doesn't care about your app's lifecycle. It fires regardless.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making one-shot alarms alarm-grade
&lt;/h3&gt;

&lt;p&gt;A standard &lt;code&gt;Importance.high&lt;/code&gt; notification won't cut it on Samsung, Xiaomi, or Huawei devices. These OEMs aggressively kill background processes and suppress notifications they deem non-essential. You need to make your notification look like an alarm clock to the OS:&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="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:typed_data'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;NotificationDetails&lt;/span&gt; &lt;span class="nf"&gt;_alarmGradeDetails&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;isWorkPhase&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;soundFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isWorkPhase&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;'bell_focus'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'bell_break'&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;channelId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isWorkPhase&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;'focus_bell'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'break_bell'&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;channelName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isWorkPhase&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;'Focus Bell'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'Break Bell'&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;NotificationDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;android:&lt;/span&gt; &lt;span class="n"&gt;AndroidNotificationDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;channelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;channelName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;channelDescription:&lt;/span&gt; &lt;span class="s"&gt;'Pomodoro phase transition'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;importance:&lt;/span&gt; &lt;span class="n"&gt;Importance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                              &lt;span class="c1"&gt;// Maximum priority&lt;/span&gt;
      &lt;span class="nl"&gt;priority:&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;sound:&lt;/span&gt; &lt;span class="n"&gt;RawResourceAndroidNotificationSound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soundFile&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nl"&gt;playSound:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;fullScreenIntent:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                  &lt;span class="c1"&gt;// Wakes the screen&lt;/span&gt;
      &lt;span class="nl"&gt;category:&lt;/span&gt; &lt;span class="n"&gt;AndroidNotificationCategory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;alarm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// Treated like an alarm&lt;/span&gt;
      &lt;span class="nl"&gt;visibility:&lt;/span&gt; &lt;span class="n"&gt;NotificationVisibility&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// Shows on lock screen&lt;/span&gt;
      &lt;span class="nl"&gt;enableVibration:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;vibrationPattern:&lt;/span&gt; &lt;span class="n"&gt;Int64List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromList&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nl"&gt;iOS:&lt;/span&gt; &lt;span class="n"&gt;DarwinNotificationDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nl"&gt;presentAlert:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;presentSound:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;sound:&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;$soundFile&lt;/span&gt;&lt;span class="s"&gt;.wav'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;interruptionLevel:&lt;/span&gt; &lt;span class="n"&gt;InterruptionLevel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timeSensitive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// Breaks through Focus&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;What each flag does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;importance: Importance.max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Heads-up notification — appears at the top of the screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fullScreenIntent: true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wakes the screen and shows the notification even when locked. This is what alarm clock apps use.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;category: AndroidNotificationCategory.alarm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tells the OS this is time-critical. Samsung's battery manager respects this category.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;visibility: NotificationVisibility.public&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Content visible on the lock screen without unlocking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vibrationPattern&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Custom vibration so the user physically feels it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;interruptionLevel: InterruptionLevel.timeSensitive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;iOS 15+: breaks through Focus mode&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;This is the difference between a notification that works on your desk and one that works in your pocket.&lt;/strong&gt; Standard &lt;code&gt;Importance.high&lt;/code&gt; gets silently suppressed by Samsung's battery manager. &lt;code&gt;Importance.max&lt;/code&gt; + &lt;code&gt;fullScreenIntent&lt;/code&gt; + &lt;code&gt;category: alarm&lt;/code&gt; does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  The dual-delivery pattern
&lt;/h3&gt;

&lt;p&gt;When the app is in the foreground, the Dart timer catches the transition first (instant feedback). When the phone is locked, the OS alarm delivers it. To avoid the user hearing two bells:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In your timer tick (foreground path)&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;_onPomodoroPhaseEnd&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alarmId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// Cancel the OS alarm (Dart beat it)&lt;/span&gt;
  &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;displayId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;       &lt;span class="c1"&gt;// Show the notification immediately&lt;/span&gt;
  &lt;span class="n"&gt;_scheduleNextPhaseAlarm&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// Schedule for the next phase&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use separate IDs for the scheduled alarm and the instant notification if you want, or the same ID if you want one to replace the other. Either way, the user sees exactly one notification.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 9: Weekly Repeating Notifications
&lt;/h2&gt;

&lt;p&gt;For notifications that fire on specific weekdays — like a project reminder every Monday, Wednesday, and Friday at 6:00 PM.&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="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;scheduleWeekly&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// 1 = Monday, 7 = Sunday&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_plugin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;zonedSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;title&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="n"&gt;_nextInstanceOfWeekdayTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="cm"&gt;/* notificationDetails */&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;uiLocalNotificationDateInterpretation:&lt;/span&gt;
        &lt;span class="n"&gt;UILocalNotificationDateInterpretation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;absoluteTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;androidScheduleMode:&lt;/span&gt; &lt;span class="n"&gt;AndroidScheduleMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inexactAllowWhileIdle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;matchDateTimeComponents:&lt;/span&gt; &lt;span class="n"&gt;DateTimeComponents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dayOfWeekAndTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// ← Weekly&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="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TZDateTime&lt;/span&gt; &lt;span class="nf"&gt;_nextInstanceOfWeekdayTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TZDateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;local&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;scheduled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TZDateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Advance to the target weekday&lt;/span&gt;
  &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scheduled&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;weekday&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;scheduled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scheduled&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;days:&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="c1"&gt;// If it's already passed this week, push to next week&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;scheduled&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;scheduled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scheduled&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;days:&lt;/span&gt; &lt;span class="mi"&gt;7&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;scheduled&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;strong&gt;Important:&lt;/strong&gt; Each weekday needs its own notification ID. If you want reminders on Monday, Wednesday, and Friday, that's three separate &lt;code&gt;zonedSchedule&lt;/code&gt; calls with three different IDs. When removing the reminder, cancel all of them.&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;// Generating unique IDs per project + weekday&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;notifId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;baseId&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;projectId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hashCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 10: Surviving Samsung Battery Optimization
&lt;/h2&gt;

&lt;p&gt;This is the step most Flutter notification tutorials skip, and it's why your notifications work perfectly during development but fail silently in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;Samsung, Xiaomi, Huawei, OnePlus, and most Chinese OEMs add an aggressive battery optimization layer &lt;strong&gt;on top of&lt;/strong&gt; Android's standard Doze mode. Even if your alarm is correctly scheduled with &lt;code&gt;exactAllowWhileIdle&lt;/code&gt;, the OEM's battery manager can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kill your app process after ~5 minutes of screen-off time&lt;/li&gt;
&lt;li&gt;Block &lt;code&gt;AlarmManager&lt;/code&gt; exact alarms from waking the app&lt;/li&gt;
&lt;li&gt;Silently suppress notifications from "sleeping" apps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why WhatsApp, Duolingo, and Telegram work but your app doesn't — Google Play Services (which delivers FCM push notifications) is whitelisted at the system level. Your app is not. You need the user to manually exempt you.&lt;/p&gt;

&lt;h3&gt;
  
  
  The solution
&lt;/h3&gt;

&lt;p&gt;Android provides a system dialog that asks the user to whitelist your app from battery optimization. You can trigger it using the &lt;code&gt;permission_handler&lt;/code&gt; package (which you already have for notification permissions):&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="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'dart:io'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter/foundation.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:permission_handler/permission_handler.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:shared_preferences/shared_preferences.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BatteryOptimizationService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;_keyAsked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'battery_optimization_asked'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;/// Check if the app is already exempted from battery optimizations.&lt;/span&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="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;isExempted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;Platform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAndroid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;status&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;Permission&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ignoreBatteryOptimizations&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&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;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isGranted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;/// Request battery optimization exemption.&lt;/span&gt;
  &lt;span class="c1"&gt;/// Shows the system "Allow unrestricted battery?" dialog.&lt;/span&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="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;requestExemption&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;Platform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAndroid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;status&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;Permission&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ignoreBatteryOptimizations&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;request&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;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isGranted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;/// Check and prompt once — call on first pomodoro start.&lt;/span&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="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ensureExemptedForPomodoro&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;Platform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAndroid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;isExempted&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="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;SharedPreferences&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_keyAsked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;requestExemption&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;prefs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setBool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_keyAsked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;debugPrint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'[Battery] Exemption &lt;/span&gt;&lt;span class="si"&gt;${granted ? "granted" : "denied"}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  When to prompt
&lt;/h3&gt;

&lt;p&gt;Don't ask on app first launch — the user has no context for why you need it. Ask at the moment it matters:&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;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;startPomodoro&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;projectName&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Ask BEFORE the timer starts — user understands why&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;BatteryOptimizationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ensureExemptedForPomodoro&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// ... start the timer, schedule the alarm&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system dialog is native Android UI — it looks official, not spammy. And because we gate it with &lt;code&gt;_keyAsked&lt;/code&gt;, the user only sees it once.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the user sees
&lt;/h3&gt;

&lt;p&gt;The dialog says something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Let LongGame run in the background?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
This app will be able to run in the background, which may increase battery usage.&lt;/p&gt;

&lt;p&gt;[DENY]  [ALLOW]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If they tap "Allow", your app is exempted from the OEM's battery killing. Your &lt;code&gt;exactAllowWhileIdle&lt;/code&gt; alarms now fire reliably even with the screen off.&lt;/p&gt;

&lt;h3&gt;
  
  
  For extra reliability
&lt;/h3&gt;

&lt;p&gt;If your users are on Samsung specifically, you may also want to link them to the device-specific battery settings. Samsung has an additional "Sleeping Apps" list that operates independently of Android's standard battery optimization. The &lt;a href="https://dontkillmyapp.com/" rel="noopener noreferrer"&gt;don't kill my app&lt;/a&gt; project maintains device-specific instructions you can reference in your settings screen.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 11: Custom Notification Sounds
&lt;/h2&gt;

&lt;p&gt;If you want distinct sounds for different notification types — like a bright bell for "focus" and a warm chime for "break" — you need three things.&lt;/p&gt;

&lt;h3&gt;
  
  
  File placement
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;android/app/src/main/res/raw/bell_focus.wav     ← Android reads from res/raw
ios/Runner/bell_focus.wav                        ← iOS reads from app bundle
assets/sounds/bell_focus.wav                     ← Optional: Flutter assets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Notification details with sound
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;NotificationDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;android:&lt;/span&gt; &lt;span class="n"&gt;AndroidNotificationDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;'channel_focus_bell'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                    &lt;span class="c1"&gt;// Unique channel ID&lt;/span&gt;
    &lt;span class="s"&gt;'Focus Bell'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;sound:&lt;/span&gt; &lt;span class="n"&gt;RawResourceAndroidNotificationSound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'bell_focus'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// No file extension&lt;/span&gt;
    &lt;span class="nl"&gt;playSound:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;iOS:&lt;/span&gt; &lt;span class="n"&gt;DarwinNotificationDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;presentSound:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;sound:&lt;/span&gt; &lt;span class="s"&gt;'bell_focus.wav'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                  &lt;span class="c1"&gt;// With file extension&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;h3&gt;
  
  
  The channel rule
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Android requires a separate notification channel for each distinct sound.&lt;/strong&gt; Once a channel is created, its sound cannot be changed programmatically — the user would need to clear app data or reinstall. Name your channels carefully the first 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="c1"&gt;// One channel per sound&lt;/span&gt;
&lt;span class="s"&gt;'longgame_focus_bell'&lt;/span&gt;   &lt;span class="err"&gt;→&lt;/span&gt;  &lt;span class="n"&gt;bell_focus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;wav&lt;/span&gt;
&lt;span class="s"&gt;'longgame_break_bell'&lt;/span&gt;   &lt;span class="err"&gt;→&lt;/span&gt;  &lt;span class="n"&gt;bell_break&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;wav&lt;/span&gt;
&lt;span class="s"&gt;'longgame_reminders'&lt;/span&gt;    &lt;span class="err"&gt;→&lt;/span&gt;  &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt; &lt;span class="n"&gt;sound&lt;/span&gt;

&lt;span class="c1"&gt;// Don't try to reuse a channel with different sounds&lt;/span&gt;
&lt;span class="c1"&gt;// The first sound "wins" and the channel ignores subsequent changes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Complete Reference
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Which mechanism to use
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Repeats?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daily notification (8:00 AM every day)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;zonedSchedule&lt;/code&gt; + &lt;code&gt;DateTimeComponents.time&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Every day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly reminder (Mon at 6:00 PM)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;zonedSchedule&lt;/code&gt; + &lt;code&gt;DateTimeComponents.dayOfWeekAndTime&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Every week&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-shot alarm (25 min from now)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;zonedSchedule&lt;/code&gt;, no &lt;code&gt;matchDateTimeComponents&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistent indicator (timer running)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_plugin.show()&lt;/code&gt; with &lt;code&gt;ongoing: true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Until cancelled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instant alert (event just happened)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_plugin.show()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Which notification details to use
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Importance&lt;/th&gt;
&lt;th&gt;fullScreenIntent&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daily nudge / reminder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Standard heads-up, doesn't need to wake the screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pomodoro phase transition&lt;/td&gt;
&lt;td&gt;&lt;code&gt;max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;alarm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Must wake the screen and break through Samsung battery killing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ongoing timer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;stopwatch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persistent but non-intrusive, lives in the notification shade&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session reminder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Standard importance, user is likely awake&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What not to use
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What goes wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WorkManager&lt;/code&gt; periodic task&lt;/td&gt;
&lt;td&gt;OS throttles it. 15-minute minimum becomes hours in Doze.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Dart Timer.periodic&lt;/code&gt; for alerts&lt;/td&gt;
&lt;td&gt;Stops when phone locks. App suspended = timer dead.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cancel()&lt;/code&gt; then &lt;code&gt;zonedSchedule()&lt;/code&gt; on app open&lt;/td&gt;
&lt;td&gt;Perpetually pushes alarm to "tomorrow." Never fires.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS Background Fetch&lt;/td&gt;
&lt;td&gt;0–2 executions per day. Apple decides when, not you.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Importance.high&lt;/code&gt; for time-critical alarms&lt;/td&gt;
&lt;td&gt;Samsung silently suppresses it. Use &lt;code&gt;max&lt;/code&gt; + &lt;code&gt;alarm&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ignoring battery optimization&lt;/td&gt;
&lt;td&gt;Works on Pixel, dies on Samsung. 70%+ of Android users are on OEM skins.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Android permissions checklist
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;When needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Always (Android 13+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RECEIVE_BOOT_COMPLETED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;If alarms should survive reboots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Exact alarms (API ≤ 33, runtime request on API 34+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;USE_EXACT_ALARM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Guaranteed exact alarms (API 33+), no user prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REQUEST_IGNORE_BATTERY_OPTIMIZATIONS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Battery exemption dialog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WAKE_LOCK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keep CPU alive during alarm processing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Notification IDs
&lt;/h3&gt;

&lt;p&gt;Use fixed IDs per notification type. &lt;code&gt;zonedSchedule&lt;/code&gt; with the same ID replaces the existing alarm. This is your friend — it's how you update content without breaking repeats.&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="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;idMorningIntent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;idDriftAlert&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;idMirror&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;idStreak&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;idTimerRunning&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;idOngoingTimer&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;idPomodoroAlarm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;98&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;idPomodoro&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;idSessionBase&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// + project hash + weekday&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Bugs To Watch For
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Bug 1: The startup hang
&lt;/h3&gt;

&lt;p&gt;Your &lt;code&gt;rescheduleAll()&lt;/code&gt; queries Firestore for personalized content. If no user is signed in, the Firestore SDK with offline persistence doesn't throw — it hangs. Your &lt;code&gt;main()&lt;/code&gt; never reaches &lt;code&gt;runApp()&lt;/code&gt;. The app is stuck on the splash screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Check &lt;code&gt;FirebaseAuth.instance.currentUser != null&lt;/code&gt; before rescheduling. Also make the call fire-and-forget with &lt;code&gt;.catchError()&lt;/code&gt; so a Firestore timeout doesn't block startup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 2: The cancel-reschedule cycle
&lt;/h3&gt;

&lt;p&gt;Covered in Step 5, but worth repeating because it's the most insidious bug. It works perfectly in development (you're always testing right after scheduling), and fails silently in production (the user opens the app after the notification time, so it's always pushed to tomorrow).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Never &lt;code&gt;cancel&lt;/code&gt; before &lt;code&gt;zonedSchedule&lt;/code&gt; for enabled notifications. Only cancel when disabling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 3: The locked-phone silence
&lt;/h3&gt;

&lt;p&gt;Your Dart timer works nicely in the foreground. You test it, the notification fires after 25 minutes, you ship. Then a user runs a pomodoro session, puts the phone down, and hears nothing. The OS suspended your app. Your timer stopped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Schedule a one-shot OS alarm for the exact phase end time. The Dart timer is for foreground UX; the OS alarm is for reliability. Let them race.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 4: The Samsung battery killer
&lt;/h3&gt;

&lt;p&gt;Everything works on your Pixel. Every alarm fires on time. You ship. Then 70% of your users (Samsung, Xiaomi, Huawei) report that pomodoro notifications never arrive when the screen is off.&lt;/p&gt;

&lt;p&gt;Samsung adds &lt;strong&gt;"Sleeping Apps"&lt;/strong&gt; and &lt;strong&gt;"Deep Sleeping Apps"&lt;/strong&gt; lists on top of Android's standard Doze. Even &lt;code&gt;exactAllowWhileIdle&lt;/code&gt; alarms are suppressed for apps on these lists. Your perfectly scheduled alarm never fires because Samsung killed your process and blocked the &lt;code&gt;AlarmManager&lt;/code&gt; wakeup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix (three layers, use all of them):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Request battery optimization exemption&lt;/strong&gt; — show the system "Allow unrestricted?" dialog on first pomodoro start. This removes you from the standard Android optimization. (Step 10)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use alarm-grade notification details&lt;/strong&gt; — &lt;code&gt;fullScreenIntent: true&lt;/code&gt; + &lt;code&gt;category: alarm&lt;/code&gt; + &lt;code&gt;Importance.max&lt;/code&gt;. The OS treats these like alarm clock notifications and is far less likely to suppress them. (Step 8)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Declare &lt;code&gt;USE_EXACT_ALARM&lt;/code&gt;&lt;/strong&gt; — this permission is always granted on API 33+ without user interaction, giving you a stronger guarantee than &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; alone. (Step 2)&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these alone is sufficient. Together, they give you the same delivery reliability as WhatsApp on Samsung.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Cost
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Cost per user&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FCM push messages&lt;/td&gt;
&lt;td&gt;$0 — not used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud Functions&lt;/td&gt;
&lt;td&gt;$0 — computed on-device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firestore reads for content&lt;/td&gt;
&lt;td&gt;$0 — bundled with existing app queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local alarm scheduling&lt;/td&gt;
&lt;td&gt;$0 — OS-level, no backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Battery optimization prompt&lt;/td&gt;
&lt;td&gt;$0 — native system dialog&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The entire notification system runs at zero marginal cost. The trade-off is that notification content is only as fresh as the user's last app open. For daily reflections and nudges, that's perfectly fine — you're computing tomorrow's content with today's data, and it's accurate enough to be useful.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is the system behind &lt;a href="https://long-game-f5520.web.app/" rel="noopener noreferrer"&gt;Long Game&lt;/a&gt;, a life auditing app for people who want to be intentional with their time. The original post explaining the design decisions is &lt;a href="https://www.mxomasuku.com/blog/how-i-built-the-long-game-notification-system-a-journey-into-notifications-and-behavioral-engineering" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>android</category>
      <category>mobiledevelopment</category>
    </item>
    <item>
      <title>The People On The Other Side Of Your Upwork Proposal</title>
      <dc:creator>Mxolisi Masuku</dc:creator>
      <pubDate>Mon, 30 Mar 2026 03:19:47 +0000</pubDate>
      <link>https://dev.to/mxomasuku/the-people-on-the-other-side-of-your-upwork-proposal-4pno</link>
      <guid>https://dev.to/mxomasuku/the-people-on-the-other-side-of-your-upwork-proposal-4pno</guid>
      <description>&lt;p&gt;Upwork is increasingly getting difficult — or so they say. I've had quite a number of friends who created an account and abandoned it, and others curious about whether it's worth starting. The conversation kept repeating itself so I decided to write this.&lt;/p&gt;

&lt;p&gt;If the Internet tells you Upwork is a scam, understand what you are actually hearing: frustration from people who didn't make it, and silence from the thousands quietly building careers on the platform. People who are making money on Upwork eat quietly. The loudest voices are freelancers turned influencers who make money coaching you, and the disgruntled ones on Reddit whose complaints come wrapped in humble brags. "I have a $400k account but Upwork treated me like this." $400k is life-changing money — but if you break the rules, Upwork is judge, jury and executioner. Your account size doesn't matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The Numbers Behind The Noise&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Upwork is not a startup hoping to find product-market fit. It is the dominant player in the freelance platform market.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://investors.upwork.com/news-releases/news-release-details/upwork-reports-fourth-quarter-and-full-year-2025-financial" rel="noopener noreferrer"&gt;Record full-year revenue of $788 million in 2025&lt;/a&gt;. Over &lt;strong&gt;$4 billion&lt;/strong&gt; in gross services volume — that's the total amount clients spent on the platform in a single year. More than 18 million registered freelancers across 180+ countries. Around 800,000 active clients. Upwork holds roughly 61% of the freelance platform market share, more than triple Fiverr's and double Toptal's. Fast Company named them &lt;a href="https://investors.upwork.com/news-releases/news-release-details/upwork-named-fast-companys-most-innovative-companies-2025" rel="noopener noreferrer"&gt;one of the Most Innovative Companies of 2025&lt;/a&gt;, and Frost &amp;amp; Sullivan gave them the Global New Product Innovation Award in 2023.&lt;/p&gt;

&lt;p&gt;AI-related work on the platform grew 60% year-over-year in 2024, and freelancers working on AI projects earned 44% more per hour than those who didn't. The average freelancer hourly rate sits around $39. Web and software development accounts for 34% of all work on the platform.&lt;/p&gt;

&lt;p&gt;Why does this matter to you? $4 Billion in circulation means this is not a dying marketplace. The money is real, the clients are real, and the opportunity is real. The question is whether you can position yourself to capture your slice of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;1. Think People First&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before you get into the Upwork algorithm debate, start here: Who are the people on the other side of your proposals?&lt;/p&gt;

&lt;p&gt;The client landscape has shifted. Three or four years ago, the typical Upwork client had money to spend, no time to look for talent, and needed an automated way to protect their interests. You saw people offering 3-year contracts at $40 an hour.&lt;/p&gt;

&lt;p&gt;Since 2025, the attitude has changed. Everyone is looking to save. But not everyone is looking to save the same way. Here's how I break down the client tiers on Upwork right now:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The top-tier client&lt;/strong&gt; understands the value of AI in speeding things up. He has money but is not willing to splash it on a slow freelancer. This client wants the best person on the job who can leverage AI to move fast, and can pay explosive amounts for it. Usually after the first successful job, these people will negotiate a long-term contract with a reliable freelancer. They don't sift through 150 proposals. They go to Upwork and say "find me someone who can do this" and the algorithm feeds them the best and boosted profiles. This is where Connects, profile optimisation and your Upwork ranking all come in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fair-price client&lt;/strong&gt; says "I have this job, I have this amount, let's work." Expect fair treatment here. This kind of client appreciates honesty, &lt;a href="https://www.upwork.com/resources/ways-to-improve-client-communication-skills" rel="noopener noreferrer"&gt;communication&lt;/a&gt; and thoroughness. These are skills you should have or be actively improving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The exploitative client&lt;/strong&gt; knows the freelance labour market is full of desperate people right now. So he dangles a carrot. $50 for a hard job. Then he piles on responsibility knowing the developer will do it just to get good reviews. Upwork has &lt;a href="https://www.upwork.com/resources/leading-voices/prepare-for-your-first-client-meeting" rel="noopener noreferrer"&gt;documentation to help you&lt;/a&gt; handle this, but you need to recognise the pattern before you're already trapped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scammer.&lt;/strong&gt; You have to beware of these. They will take you for everything you've got. Steal, use you, hack you or worse. Follow Upwork's guidelines on surviving scammers and go to the unofficial Upwork Reddit channel — &lt;a href="https://www.reddit.com/r/Upwork/" rel="noopener noreferrer"&gt;r/Upwork&lt;/a&gt;. It's unhinged and town-square-like, but it will help you catch up fast with how scams are evolving.&lt;/p&gt;

&lt;p&gt;Pro tip: Read &lt;a href="https://www.reddit.com/r/Upwork/wiki/index/scamguide/generalguide/" rel="noopener noreferrer"&gt;The General Guide To Upwork Scams&lt;/a&gt; on Reddit&lt;/p&gt;

&lt;p&gt;Once you know the people, you know who you need to become to be chosen. You can play the algorithm all you want, but on the other end of everything you type, there is a person who is as interested in gaining value and as afraid of losing money on bad product as you are.&lt;/p&gt;

&lt;p&gt;Think people first. Upwork is a community brought together by the platform. The rules matter as much as the people they serve.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;2. Play The Long Game But Work Hard On Your Short Plays&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Freelancing is hard and competitive. Upwork, being the top platform, is even more competitive. Whether you are new or a seasoned freelancer looking to reinvent yourself: your patience will be tested and your persistence will be rewarded.&lt;/p&gt;

&lt;p&gt;The long-term goal is being on a stable, well-paying contract. Being a top freelancer that Upwork recommends to its best clients. Getting paid consistently.&lt;/p&gt;

&lt;p&gt;The mid-term goal is building up smaller, shorter contracts. Quick jobs. Building your skill set, networking, studying the market. You don't get to the long-term goal without passing through this one unless you are extremely lucky.&lt;/p&gt;

&lt;p&gt;The immediate goal is your short plays. This is where the real work happens and where most freelancers give up too early.&lt;/p&gt;

&lt;p&gt;In my experience, I got lucky in a sense — I got to Top Rated Plus with just 3 jobs. But those 3 jobs amount to 2,240+ hours. My takeaway: there is more value in sustaining a long-term business relationship than in chasing volume. Your mileage may vary, but there is no substitute for good work and maintaining good relationships.&lt;/p&gt;

&lt;p&gt;As you improve your profile, think of every iteration of profile development as a play that must be analysed and improved. Every proposal is a data point that needs to be dissected and built on.&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.amazonaws.com%2Fuploads%2Farticles%2F5weexyl2ip1o2l01hsdg.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.amazonaws.com%2Fuploads%2Farticles%2F5weexyl2ip1o2l01hsdg.png" alt="Screenshot 2026-03-27 at 23.50.39.png" width="800" height="570"&gt;&lt;/a&gt; &lt;em&gt;Source: &lt;a href="https://www.reddit.com/r/Upwork/comments/1s5ee3s/please_review_my_stats/?utm_source=share&amp;amp;utm_medium=web3x&amp;amp;utm_name=web3xcss&amp;amp;utm_term=1&amp;amp;utm_content=share_button" rel="noopener noreferrer"&gt;r/Upwork&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Look at this from r/Upwork. 49 proposals sent in 90 days. Only 7 were even viewed. 3 interviews. 2 hires. $1,500 earned in the first few months on the platform. This is what the grind looks like. Most of your proposals will disappear into the void. The game is making sure the ones that land are good enough to convert.&lt;/p&gt;

&lt;p&gt;Read job posts. What skills are clients asking for? What's the overall application tone on key skills? It's all about patience, playing the long game and relentlessly looking to improve.&lt;/p&gt;

&lt;p&gt;Right now, in most of the job posts I see, clients are prioritising freelancers who have an AI-first attitude. n8n, Agentic Workflows, Supabase, Firebase, Cursor, Antigravity top almost every post. These can be pointers but they can also get you panicking and chasing the wind. When you see trends like these you can either choose to be a generalist or a specialist. I chose to specialise. Depth beats breadth when a client scans 50 proposals and needs to trust that you've solved their exact problem before.&lt;/p&gt;

&lt;p&gt;In the job posts, listen to what clients are saying about what they need. More importantly, listen to what they're saying about the freelancer they want to work with. Become that person.&lt;/p&gt;

&lt;p&gt;Again. Think people first.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;3. Follow The Rules&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The most nerve-wracking moment in your Upwork journey will be right after a client says &lt;em&gt;"Hey, I saw your proposal. I'm interested. Let's talk."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At this moment, your position must shift: DO NOT THINK PEOPLE FIRST. Think Upwork first.&lt;/p&gt;

&lt;p&gt;Do not be hoodwinked. Your main concern shouldn't be what the other person is saying. It should be: what does Upwork say about this? For every response you send, you must consider the Upwork funnel and &lt;a href="https://www.upwork.com/resources/client-red-flags" rel="noopener noreferrer"&gt;playbook&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Failure to do so will result in a ban. Back in the day the ban was effective immediately and non-negotiable, though this may be shifting since Upwork now shows you account health status.&lt;/p&gt;

&lt;p&gt;Things to know: Upwork is ruthless on freelancers who share personal contact details before a contract is finalised. Clients who want to take the conversation off-platform before a contract is signed are a red flag. On top of that, you should also be able to negotiate boldly without putting yourself in the corner, all while studying your client for scammer patterns.&lt;/p&gt;

&lt;p&gt;Have your own rules as a freelancer and follow them. Know Upwork's rules and follow them. Listen to the client's rules and expectations and see if you are prepared to follow them. This is the foundation of your Upwork survival journey.&lt;/p&gt;

&lt;p&gt;The interview and contract negotiation phase is the most dangerous period of your freelance career. When you see people talking about how a single mistake cost them everything, this is the phase they're talking about. This is why the &lt;a href="https://www.reddit.com/r/Upwork/" rel="noopener noreferrer"&gt;unofficial Upwork Reddit channel is a must-visit.&lt;/a&gt; &lt;/p&gt;

&lt;p&gt;Protect yourself. Protect your account.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Last Word&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;How do you survive Upwork as a freelancer? This entire article can be distilled into one statement: Know and study the people on Upwork, and follow the rules.&lt;/p&gt;

&lt;p&gt;Link to the You can also check the &lt;a href="https://www.reddit.com/r/UpworkOfficial/" rel="noopener noreferrer"&gt;official Upwork&lt;/a&gt; subreddit, though it's much smaller than &lt;a href="https://www.reddit.com/r/Upwork/" rel="noopener noreferrer"&gt;r/Upwork&lt;/a&gt;. &lt;/p&gt;




&lt;p&gt;&lt;em&gt;My name is Mxolisi Masuku. I write blogs like this and I have a newsletter called &lt;a href="https://www.mxomasuku.com/newsletter" rel="noopener noreferrer"&gt;Systems For Humans&lt;/a&gt; where I talk about the software world beyond the technical considerations. For me, the software world works better when you think about who is on the other end as the guiding principle of whatever you are going to build. &lt;a href="https://www.mxomasuku.com/newsletter" rel="noopener noreferrer"&gt;Subscribe&lt;/a&gt; and follow if you like this.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>upwork</category>
      <category>freelancing</category>
      <category>career</category>
    </item>
    <item>
      <title>Build Your Stripe Checkout Like A Lawyer, Not Just A Developer</title>
      <dc:creator>Mxolisi Masuku</dc:creator>
      <pubDate>Sat, 28 Mar 2026 10:58:13 +0000</pubDate>
      <link>https://dev.to/mxomasuku/build-your-stripe-checkout-like-a-lawyer-not-just-a-developer-67j</link>
      <guid>https://dev.to/mxomasuku/build-your-stripe-checkout-like-a-lawyer-not-just-a-developer-67j</guid>
      <description>&lt;p&gt;Most developers and vibe coders build their Stripe checkout to accept payments. They don't build it to survive disputes with people, their customers. In Stripe's ecosystem, a single ban or badly handled chargeback can cost your business everything. This article is about how to make sure it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Your Refund Policy Is Your First Line of Defence&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before you write a single line of checkout code, you need a refund and chargeback policy. Not a template you copied from the internet. A policy you have actually stress-tested against real scenarios.&lt;/p&gt;

&lt;p&gt;For example, I am working on a platform that sells music beats. These fall under digital products. My thought process was: What happens when a customer pays for a beat, downloads it, then says they don't want it anymore and claims they never used it? What if they didn't download it? How would I even prove that? What if they come back after 2 days? 2 weeks? 2 months?&lt;/p&gt;

&lt;p&gt;These disputes will come. &lt;/p&gt;

&lt;p&gt;Some will be genuine, some will be fraudulent. Your policy is the wall between you keeping that money and Stripe taking it back. &lt;/p&gt;

&lt;p&gt;This policy must be visible to the user. The user must accept it before they complete the purchase. The usual feature on most sites is a checkbox with: "I accept and agree to the terms and conditions." Simple. But if it's not there, you have nothing to stand on.&lt;/p&gt;

&lt;p&gt;When building, you want to spend more of your initial time in the &lt;a href="https://www.youtube.com/watch?v=C-KFUrNw71U" rel="noopener noreferrer"&gt;Stripe Sandbox&lt;/a&gt; where you test all of this before real money is involved. Play both God and customer. Simulate every scenario you can think of. Who has access to the payment gateway and at what stage? How are payments reflecting on your dashboard? Don't take the Sandbox for granted because it is the only environment where mistakes cost you nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Capture Every Event. Prove Everything.&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;When a dispute hits, Stripe doesn't take your word for it. They want evidence. The card provider wants evidence. And if you haven't been recording the right events from the start, you have nothing to give them.&lt;/p&gt;

&lt;p&gt;Every interaction between the user and your checkout process needs to be logged. Every click, every purchase, every policy agreement. What IP address did the user have? Was it consistent with their usual IP? Was the purchase made from a valid session? Did the user explicitly agree to the refund policy? Did you deliver the product? You need to be able to prove and demonstrate all of this with timestamps and unique IDs.&lt;/p&gt;

&lt;p&gt;This is the part where you have to be deliberate with your AI. If you are vibe coding your checkout, there is a real chance your AI won't build evidence capture unless you specifically ask for it. AI is great at making the payment flow work. AI is not great at thinking about what happens when a customer disputes that payment six weeks later. You have to stress this requirement yourself.&lt;/p&gt;

&lt;p&gt;Pro tip: Stripe has an AI Assistant for general queries and Agent Skills and Connectors for deeper integration. &lt;a href="https://claude.com/connectors/stripe" rel="noopener noreferrer"&gt;Use these if you are on Claude.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Event logging should be built as a feature not an afterthought. You want to be able to export all the evidence Stripe and card providers require, as a PDF or JSON, with all the required fields populated. When the dispute comes, and it will, you pull the file and submit it. That's the difference between keeping the money and losing it.&lt;/p&gt;

&lt;p&gt;For reference, look at &lt;a href="https://docs.stripe.com/api/disputes/object#dispute_object-evidence" rel="noopener noreferrer"&gt;Stripe's dispute evidence object documentation&lt;/a&gt;. It tells you exactly what fields they expect: customer email, customer IP, product description, proof of delivery, refund policy disclosure, and more. Build your logging around that schema and you won't be scrambling when the first chargeback arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Send Receipts and Invoices. Build Trust Before You Need It.&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;You can configure Stripe to send customer emails in Settings → Business → Email → Customer Emails. This isn't a legal requirement but think about it from the human side. You just made a $240 purchase and you don't get a receipt in your email. That's how distrust starts. That's how chargebacks start.&lt;/p&gt;

&lt;p&gt;Receipts, invoices, upcoming deductions, subscription reminders. These are not nice-to-haves. They are evidence that you communicated with your customer. They are proof that the customer knew what they were paying for and when. If a dispute lands, a clean email trail showing receipts sent and subscription reminders delivered tells a very different story from silence.&lt;/p&gt;

&lt;p&gt;If you want to go beyond Stripe's default emails and build custom branded ones, &lt;a href="https://www.youtube.com/watch?v=Avp1OOMH2Z0" rel="noopener noreferrer"&gt;a tool like Resend works well for this&lt;/a&gt;. You create React template emails, hook them to your Stripe webhooks, and fire a custom receipt whenever a &lt;code&gt;checkout.session.completed&lt;/code&gt; event lands. Resend gives you 100 emails a day for free. If you are hitting 100 emails a day, that means you got 100 customers a day and you can afford their $20 a month subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Build For Humans. Defend Against The Worst Ones.&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The thread running through this article is that you should build your software for human behaviour. Defend against malicious intent and protect yourself from legal punishment at the same time. &lt;/p&gt;

&lt;p&gt;Most startups get burned not because their product was bad but because they didn't think about what happens when a real person with real emotions and real incentives interacts with their payment system. People forget what they bought. People regret purchases. People lie. And some people game the system because they know most small developers don't have their evidence in order.&lt;/p&gt;

&lt;p&gt;Your checkout is not just a technical flow. It is a legal document, a communication channel and a defence mechanism. Treat it that way from day one.&lt;/p&gt;

&lt;p&gt;Additional Reading&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stripe as the authority on &lt;a href="https://stripe.com/resources/more/ecommerce-chhttps://stripe.com/resources/more/ecommerce-chargebacks-101argebacks-101" rel="noopener noreferrer"&gt;why chargebacks happen and how to prevent them&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;If you are looking &lt;a href="https://www.reddit.com/r/SaaS/comments/157rorx/are_stripe_disputes_impossible_to_win/" rel="noopener noreferrer"&gt;how rough things can get on chargebacks&lt;/a&gt; check this subReddit. The question: Are Stripe Chargebacks impossible to win.
&lt;/li&gt;
&lt;li&gt;Another Stripe Blog read: &lt;a href="https://stripe.com/blog/can-ai-agents-build-real-stripe-integrations" rel="noopener noreferrer"&gt;Can AI agents build real Stripe integrations? We built a benchmark to find out&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;If you ever decide that Stripe is not for you. &lt;a href="https://www.youtube.com/watch?v=UUsOWyqUCjw" rel="noopener noreferrer"&gt;Here is a video on alternatives.&lt;/a&gt; &lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;My name is Mxolisi Masuku. I am a software engineer and a freelancer on Upwork. If you liked this, subscribe to my newsletter, Systems for Humans, where I write about the software world beyond the technical considerations.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>productdesign</category>
    </item>
  </channel>
</rss>
