-
-
Logo
-
DueDog 1.5 on iPhone, the All tab.
-
What is over goes quiet: expired purchases and settled fines collapse at the bottom.
-
No account, no cloud, dark mode included. Everything stays on the iPhone.
-
Paid it? Swipe right to mark a fine as settled, with undo.
-
The deadline runs from the day the letter arrived, which you confirm. The model never picks it.
-
All deadlines in one list: receipts, contracts and traffic fines, the most urgent first.
-
Fines get their own tab. The Spanish reduced payment is shown to the cent, rounded up.
Traction
Small numbers, stated plainly. From 24.08. to 23.09.2026 our five live apps together were downloaded without a single euro of marketing: no ads, no paid acquisition, no launch campaign, every unit organic. DueDog's share of that is the modest one, and it is why 1.5 exists. On 30.08. we measured our own store page: DueDog at 90 impressions while our battery app sat above 600, same shelf, same effort. The name was the variable left, so we renamed it for receipts, and 1.5 adds the third everyday document with a search word of its own (multas, Bußgeld, fines) and puts it in the name in all four storefronts.
What's new since the first submission (30.08.2026)
The entry went in against 1.1. Since then:
- 1.2 to 1.4 (live): the store rename to the receipt identity (1.2), the "Next deadline" widget in four families with names hidden on a locked screen (1.3), and the appearance setting plus a quiet update card (1.4).
- 1.5 (build 33, live): fines as a third document kind; four always-visible folder tabs (All | Purchases | Contracts | Fines) with a capped All list; a quiet, collapsed section for whatever is over; swipe right to settle a fine, with undo; "1 day left" instead of "1 days left"; and the new store name DueDog: Receipt·Warranty·Fines (es-ES Ticket·Garantía·Multas, de-DE Bon·Garantie·Bußgeld, es-MX Recibo·Garantía·Multas).
Inspiration
Every purchase has a return window and a warranty. Every contract has a notice period. Almost none of them reminds you, and the ones that do want your documents in their cloud, because your cancellation is their commission. That is the whole business model of the incumbents in this category: a service that cancels for you is paid when you switch, so it has an interest in your paperwork that is not your interest. We wanted the opposite of that: a tool that reads the paper, works out the one date on which you have to decide, and keeps everything on the phone. The privacy promise is not a feature here, it is the product.
The app started as a contract app, and the most useful thing that happened to it was being told it had the wrong half in front. The developer, three sentences, verbatim: "when I tap scan under purchases I should not have to pick whether this is a contract or a purchase, obviously it is a purchase. Purchases should also be the default view, contracts are not everyday life. Everyday life is a shop purchase, an online order (how do we make that easy? screenshots from the app?)" Three sentences about one insight. The app had been built around contracts, and the deadline a person actually holds in their hand is a receipt. The list has opened on purchases ever since, as a constant rather than a remembered choice, because remembering it would let one contract visit in March own the front page until May. (1.5 opens on "All" instead, still a constant, and purchases, being the most common kind, are most of what "All" shows first.)
The store agreed with a delay and a number. DueDog sat at 90 impressions while our own battery app sat above 600, same shelf, same effort in images and text. Everything that could move without touching the name had already moved. What was left was the name, and the name said "Contract Deadlines", which describes something a person does once a year. It said "Receipts & Warranty" from 1.2 and says "Receipt·Warranty·Fines" since 1.5, in every storefront, because an app is found in the moment somebody has a problem, and the question is not which word describes the app most precisely but which word occurs to somebody more often.
The third document came from the same place. A traffic fine is a letter in the letterbox, not a contract, and it carries the least forgiving deadline a household meets: a Spanish traffic fine is commonly halved if paid within twenty days, and a German one commonly leaves two weeks to object. It is also the document people are least likely to want in somebody's cloud. The developer's decision, translated from the German note: "multas belongs in DueDog: make DueDog bigger and promote it." No new app, no fourth screen: a third tab, a third answer to "what is this?", and the same rule as for receipts, that the app counts the days and never states the law.
What it does
One list, four folder tabs (since 1.5). All | Purchases | Contracts | Fines, always visible, as tabs on a folder rather than a control that can collapse into a menu. The app opens on All, sorted by the next deadline across every kind, overdue first; each row keeps its own kind's marker, so VoiceOver hears what it is. All shows the first 20 and then "Show N more" (10, 20, 50 or everything, a setting); search always searches everything, because a hit hidden behind "show more" is not a hit.
Purchases. Photograph the paper receipt, or take a screenshot of the order page in the shop's own app and pick it from your photo library. One image is enough. The on-device model reads shop, date and amount into a form. You pick the return window and the warranty from the usual chips (14 / 30 / 100 days, 2 / 3 years, plus "other"), and the app counts the days to the last one. Those chips are the line the app will not cross: they are not a table of law, and the schema the model fills has no field for them at all, which a test holds in place. The device region may preselect a chip (three years in Spain, two elsewhere) and can never decide one, because a preselection is visually a selected chip like any other, one tap away from something else, and nothing downstream can tell how a chip came to be selected. Next to it stands a neutral sentence about what is customary rather than what you are entitled to, and one "this is not legal advice" line in the purchase context.
Contracts, the other half. Car insurance, broadband, the gym, the rented router. Photograph the contract or the renewal letter, or share a PDF into the app from Mail or Files. The model reads provider, start date, minimum term, notice period and any renewal clause; the engine, not the model, computes the real cut-off date.
Fines, the third document (since 1.5). A German
Bußgeldbescheid, a Spanish multa, a UK penalty notice. Photograph the letter
or share the PDF; the model reads authority, reference number, amount and
currency, the date printed on the letter, a short description of the offence
and a hint at the country. Then the form asks the one question the model is
never allowed to answer: "Received on", the day the letter actually
reached you, defaulting to today, because that is the day the deadline runs from,
and the letter's own date is read precisely so that it is not mistaken for it.
The schema has no field for a deadline or for that day, and a test holds it so.
The deadline windows are presets by jurisdiction, only ever a preselection:
Spain's pronto pago and alegaciones (20 days each, with one neutral sentence
that paying the reduced amount ends the procedure and waives objections), the
German two-week objection window, the UK discount window as "often, but it
varies", and "other country" preselects nothing. The model's country hint may
propose the origin, marked as an AI suggestion; otherwise the device region
does. The reduced Spanish amount is shown in the reader's locale, computed in
Decimal and rounded up to the cent, because the figure somebody transfers
must never be half a cent short.
Counting is conservative, and says so. The delivery day counts as day one, which is exact for the UK reading and one day earlier than the Spanish and German ones; a deadline is never extended over a weekend or a public holiday, and a sentence in the form says the real end may be later. Reminders are a trio, a week, three days and a day before, all free, because an objection has to be written and posted and a transfer has to arrive, and the contract defaults would already lie in the past. One reminder stream per day rather than per deadline, so a Spanish fine whose two windows close together does not ring twice. Fines appear in the widget and the month view. Settled (paid or answered) is a switch: the entry stays, every reminder is cancelled. A fine whose window passed unpaid does not go quiet: the money is still owed, so it stays at the top in red, "Deadline passed – still open", until you mark it settled. Fines are not behind the unlock.
What is over goes quiet (since 1.5). A purchase whose return window and warranty have both run out, and a fine marked settled, leave the active list for a collapsed section at the bottom ("Expired", "Settled", or "Expired & settled" in All, titled by what is inside). The rows there have no card surface and no pill, one quiet ink measured to stay readable on every background, and "Expired on 19 Sep 2025" instead of a countdown. Nothing is archived, moved or deleted; it is a display state, never stored. Contracts never go quiet: a missed cancellation day stays red.
Swipe right to settle (since 1.5). On an open fine, a swipe to the right reveals "Settled" and marks it; an undo bar with the fine's name stays for five seconds, twelve under VoiceOver. Only open fines take the gesture: purchases, contracts and settled fines do not move at all, and there is deliberately no swipe to delete, because deleting asks for confirmation and stays in the context menu and the detail screen. VoiceOver users get "Mark as settled" as a named action.
The mandatory confirmation screen. Nothing is saved before a person has seen it. Every field is editable, durations are entered as a number plus a localised unit menu rather than as text the user has to phrase in a schema convention, dates come from a date picker with an "exact day unknown" switch for month-precision, and every value the model produced carries "AI suggestion, please check" until you confirm or change it. The label is derived rather than maintained: it holds exactly while the current trimmed value is still the suggested one, so editing removes it in the same step as the edit.
Then it reminds you. Free: six weeks and one week before a contract's cut-off, and for a short purchase window (anything up to 30 days) three days and one day before, because applying the contract defaults to a 14 day return window would promise reminders that already lie in the past and then drop them silently. Optionally an all-day calendar entry per deadline, whose alarms mirror exactly the reminder rules chosen on the record; re-exporting updates that event rather than duplicating it, deleting the record removes it, calendar permission is requested write-only first and fully only when something actually has to be read back, and the calendar is chosen through the system chooser. A Google or Exchange account configured in iOS shows up there by itself, because DueDog writes locally and iOS syncs. Never a provider API.
A widget, since 1.3. "Next deadline" in four families: the next one, the next three, and the days remaining on the lock screen in two accessory shapes, with two empty states that are deliberately not the same one ("no deadline yet" against "all done"). It does not read the database. The SwiftData store sits in the app container and deliberately not in the app group, so no extension can open a saved contract; the app writes a snapshot of at most five rows as JSON into the shared container and reloads the timelines. Every name is marked privacy sensitive, because a contract name read over your shoulder off a locked screen is exactly what this app promises not to do. The day count stays visible, because how long you still have is not a secret and a fully redacted widget says nothing at all.
And two small things since 1.4: an appearance setting (system, light, dark) for this app alone, and a quiet card at the top of the list when a newer version is in the store. Never modal, and it draws nothing when there is nothing to say.
Nothing goes to a server. No account, no upload, no analytics, no ads, no cloud sync. A receipt carries what you bought and what you paid; a contract carries your name, your address, your customer number and sometimes your bank details. Text recognition and Apple's on-device language model both run on the phone, so there is no server receiving your documents, because there is no server. The privacy label reads "Data Not Collected" because that is the only label that matches how the app is built.
Three languages on three independent axes. The interface follows the system language in German, Spanish and English (with Apple's per-app language page linked from Settings rather than a picker of our own); the input language is detected and never assumed, with text recognition configured for all three simultaneously and the document's language decided by Apple's language recogniser; and the output language selects the prompt template. A Spanish invoice read on a German phone is the normal case here, not an edge case.
What it costs. Scanning, saving, the standard reminder pair, the sorted list and the calendar export are free, purchases and contracts alike, with no limit and no subscription; so are fines in 1.5, with their full reminder trio. A one-time lifetime unlock adds the month view ("what runs out this month?"), reminder points you set yourself including a free-form lead (for a fine, one more on the day itself), and the PDF export of your contract deadlines. Three optional tips (a coffee, a tapa, a dinner) unlock nothing.
How I built it
Swift 6 with strict concurrency, SwiftUI, SwiftData, deployment target iOS 26, project defined in XcodeGen, three targets (app, share extension, widget). No third-party dependency except the purchase layer described below. 553 automated tests at 1.5 (417 before the fines session), and the parts worth testing are deterministic on purpose.
The model reads, the engine computes. This is the load-bearing sentence of
the whole app. The model fills a @Generable struct and never a date arithmetic
result: for a contract, provider and category are required (every document
has both, and an empty value is a visible failure) while start date, minimum
term, notice period and renewal clause are optional, because a required field is
a field a small model will fill whether the answer is on the page or not. An
optional property is genuinely absent from the schema's required list, decodes
to nil and disappears from the export, so "left out" and "empty string" stay
distinguishable. Dates are requested as ISO-8601 and never as localised strings,
and the display formats them per locale afterwards. The discipline lives in the
per-field @Guide descriptions rather than in the prompt template, because a
description travels with the field into the schema and stands in front of the
model exactly while it is decoding that property, while the template has to stay
short. Prompt templates are versioned files under Shared/Prompts/<language>/,
not string literals.
The deadline engine is deterministic and unit-tested by time travel.
Cancellation date is contract end minus notice period, with the renewal logic
next to it. A purchase is the same engine with renewalMode = .none, the
purchase date as start and the chosen chip as the term, and the purchase day
counts as the first day of the window (start + term - 1), which is one day
more conservative than "14 days from purchase" and is stated as a sentence in
the form rather than applied silently. Month boundaries, year boundaries and
purchases against contracts each have their own time-travel tests, and the gate
tests hold that without the unlock no extra rule fires and the month view is not
merely undrawn but never computed.
Fines are a third model, not a wider purchase. A different anchor (the day
the letter arrived, not the day on the document), two deadlines from one anchor,
and a state the other kinds do not have, settled. Every property has a
default, so the store migrates lightly and leaves the other tables alone. The
arithmetic lives in one pure type that delegates to the same engine in our
shared package, with no second calendar implementation, and it is pinned by
time-travel tests across daylight-saving changes in Madrid, London and Los
Angeles in both directions, month ends, a leap year, New Year, a Sunday and
Christmas Day. The extraction template (duedog_fine_v1.md, per language)
stays short and positive, and the OCR text goes only into the prompt.
A swipe on a list that is not a List. The deadline list is a scroll view of
cards, so SwiftUI's swipe actions do not apply, and a drag gesture there either
steals the scroll or shares every touch with it. The answer is a UIKit pan
recogniser bridged into SwiftUI, because it is asked before it begins:
horizontal-to-the-right begins, everything else fails at once and stays with the
scroll view, the tap and the context menu. The decisions are pure and tested:
direction only after 10 pt, horizontal means at least 1.3 times the vertical
travel, armed at 40 % of the width or at 20 % for a fast flick whose projection
would carry across. Undo is exactly the detail screen's reopen path, and a test
checks that the same reminder rules are pending again afterwards.
The paid surface is locked in the right direction. The unlock gate used to ask "is this rule id in the paid list", which returns false for every id it does not know, and a user-defined reminder lead is precisely an id the catalogue does not know, so it would have been free. It now asks "is this id not in the free list". The free pair is unchanged; anything new is locked by default, and that is the direction a paywall has to fall. The custom lead needed no new field either: a record has always stored its reminders as rule id strings, so a free-form lead is just an id the catalogue does not carry, which meant no SwiftData migration and no second storage location.
Documents arrive by four routes and all four end in one inbox. Camera and
photo library, a PDF shared into the app from Mail or Files, and, since we
declared PDF as a document type, "Open in DueDog" from anywhere in iOS. The
share extension takes bytes and never processes them itself (memory limits), and
it cannot open its host app: NSExtensionContext.open is documented as
unavailable for share extensions and answers false in most hosts, so the panel
used to just vanish, indistinguishable from nothing having happened. It now ends
with a sentence ("saved, open DueDog to continue") and deliberately without an
"open DueDog" button, because promising exactly the thing this context is not
allowed to do would be worse than the silence it replaces. A local notification
two seconds later is the one-tap way across. A multi-page PDF gets a thumbnail
grid whose pages are rendered at 300 dpi and read in the background, badged
against a deterministic per-language keyword list, best page preselected, up to
three pages chosen and concatenated into a single extraction run whose context
budget is estimated before the call and trimmed at the end if needed, visibly
and never silently.
The purchase layer. RevenueCat sits in front of StoreKit 2 for the lifetime
unlock (es.freshlab.duedog.unlock.lifetime) and the three tips, pinned to an
exact SDK version, and since our chassis session on 01.09.2026 it lives in the
shared package in two halves: the SDK-neutral half (backends, failures, unlock
protocols, the StoreKit store) and the RevenueCat half, with the dependency
pointing one way only, so an app in the portfolio without this exception links
no payment SDK at all. DueDog itself keeps four strings: its Info.plist key, its
log subsystem, its cache suite and its unlock product id. There is no RevenueCat
paywall and no offerings dependency: products are fetched by identifier and the
supporter section is our own. A build with no key falls back to StoreKit 2
completely, which is not a theoretical path, because the 1.0 in the store was
exactly that build. Privacy is kept literally: no app user id, automatic device
identifier collection off, diagnostics off, and the SDK's cache in its own
defaults suite (renaming that suite would orphan the anonymous app-user id on
every device that has already launched a keyed build). The privacy label stays
"Data Not Collected". Gifts go through App Store Connect promo and offer codes,
redeemed through a "redeem a code" row in Settings that opens the system sheet:
a redemption is an ordinary StoreKit transaction that the gate and RevenueCat
both recognise without special handling, and we will never build our own token
system for it.
Challenges I ran into
One PDF, six sessions, and every step of it was a measurement. A developer shared a two-page vehicle invoice into the app from another app's share sheet and reported two things: no banner to tap across, and "I select the pages but DueDog does not recognise them." The document was read locally and never committed; the fixtures are synthetic and share only its structure. What that one file exposed, in order:
- A swallowed page that looked like an empty one. Marking a page unreadable wrote an empty string into its text, and "has been read" was "text is not nil", so a page the reader had thrown on was indistinguishable from a page that was read and genuinely empty. The selection handed the empty page straight to the extractor and never took its own second read, which its own doc comment promises. Why 307 green tests never saw it: every page source in the repo was a fixture that returns an empty string and never throws.
- The failure was destroyed rather than carried. Two
try?threw away everything the failure knew, which made "no text found" the only end state a shared PDF could ever reach, and that sentence advises more light, on a document nobody ever photographed. It was also marked retryable, so the user ran in a circle. - Six error cases were folded onto one string. The app knew which case it was, the value was right there in the switch, and it said none of it, so a photo from the device identified nothing. Every case a user can meet now has its own sentence, and a test holds that they stay distinguishable, because those sentences are the measuring instrument of the next device round. The two "our own delivery is broken" cases share one sentence that says so, rather than sending somebody off to re-photograph a page that was never the problem.
- Then the real fault named itself: the model rejected the page for its language. The decisive experiment was the developer's, not ours: a screenshot of the same page one, whole page, full item table, all the numbers, goes through the photo path without an error. Same content, same model, same instructions. So the difference was not the content.
- Two sessions of closing text differences one at a time were a road that does not end. The full matrix settled it: page two alone passes, page one alone fails, page one as a screenshot passes. So a chosen page now takes the photo path as an image, the very function the screenshot passes through. The paths are not aligned, they are the same path, and no difference is left that could fail. Two or three pages keep the text route, because concatenation is the point there and an image cannot express two pages.
- And our own fix was making it worse. The language ladder we had built filtered the page down to lines that look like writing. Measured on the real text: the whole page reads as Spanish at 0.941 with Catalan a weak 0.046, while the filtered version read as Catalan at 0.822, eighteen times stronger. Throwing away the numbers leaves short fragments, and a heap of Iberian fragments reads as Catalan where whole sentences read as Spanish. We had built a step that fed the model precisely the text our own hypothesis named as the reason for rejection. A second stage now drops what the recogniser cannot judge at all, which is the opposite of the rule we wrote the session before, and the result measures 0.959, better than the untouched text. That is the first time the ladder was a measured improvement rather than a hope.
Rectification is a trap on a page we rendered ourselves, three times. Text recognition rectifies before recognising, which is right for a photo of a page held at an angle and wrong for a flat, borderless render: the same three synthetic renders read as one heading and two empty strings with detection on, and completely with it off. Two of three pages became nothing, and downstream that is "no text found", the sentence about lighting again. The same class of error came back on the rebuilt screenshot rung, where the rectifier grabbed the footer block, decided that was the document and returned the footer alone. Both are regression tests now.
A banner that could not exist. The handover notification fired immediately, which is while our own share extension is still in the foreground, and iOS shows no banner for an app that is already in front. The extension counts as DueDog. So the message landed silently in Notification Centre on every build, independently of the permission, and the doc comment carried the disproved assumption word for word. A two-second trigger fixed it: the sheet is closed by then, the host app is back in front, and there the banner is the one-tap way across that the handover was designed to be. A scheduled trigger belongs to the system rather than to the process that registered it, so completing the extension request does not cancel it.
"Nothing happens when I click" on the PDF export, and it was two things. The row wrote the file synchronously in the tap handler and then handed everything to the share sheet, so the app was simply absent for the wait. Measured rather than assumed: writing the file is milliseconds, and a test pins that so a future slow case is read as share-sheet handover time instead of suspecting the renderer. The second cause was a silent disable: the locked half of that condition always had its explanatory sentence and the empty half had silence, and since the app opens on purchases, a user with only purchases hits exactly that state and no other.
A control that measured itself into a menu, forever. On the first 1.5 build the three-way switch above the list had collapsed into a small menu ("Fines ⌃⌄") that was easy to miss. It looked like a German-is-long problem and was not. A log line in the simulator showed the first measurement at 166.3 pt against 216.9 pt needed, so: menu; the next measurement read 81.7 pt, so: menu for good. The fitting rule measured the picker's own frame, and inside a scroll view a segmented picker hugs its labels rather than filling the row, so it measured the labels' width, which the equal-segment model can never satisfy; and once the menu stood, the same measurement measured the menu. Every language failed the first measurement. A decision that measures its own output cannot recover. The replacement is a control with no second form: folder tabs that read the width of the row from a frame that always fills it, never truncate and never collapse, share one text scale down to 0.6, stop growing at the largest standard size and hand over to the Large Content Viewer above it, the way iOS's own tab bar does. Tests hold German, Spanish and English at three row widths and every text size up to that ceiling, and that a fifth tab would still fit.
The compiler crashed, twice, on our own view. A grown list body crashed Swift 6.3.3 rather than merely slowing it down, so the list arithmetic moved into a pure, testable type; a second crash came from passing a method reference as a binding setter, where an explicit closure is fine.
And the standard review question came anyway. All three of our first submissions got "Guideline 2.1, Information Needed" about eight hours after submit, in identical wording, with no substantive objection. It costs about a working day for a young developer account, and we now write the eight answers into the review notes at submission time and shoot the screen recording to a script on a freshly installed build. Measured since: a first release takes about 45 hours, an update about 9, and 1.3 came back approved in under three.
Accomplishments that I'm proud of
The AI label is derived, not maintained. The original bug was that the set of suggested fields never removed anything: no remove, no didSet, no onChange, while our own comment and our EU AI Act note claimed an edited value drops the label. The fix was not another piece of wiring but the removal of the wiring: the tracker stores the model's suggestion per field, and the label holds exactly while the current trimmed value still equals the suggested one. A maintenance obligation was the bug, so the answer was to have none. Article 50 transparency applied per field rather than per screen, plus a disclosure sentence on the onboarding privacy page and machine-readable marking of AI-produced fields in the exported PDF's metadata.
An app in this category that refuses to state the law. The earlier attempt at a purchases tab died on exactly that: a rule set per country means legal advice, maintenance and support. The way out was not to have the rule set at all, and the schema having no field for a return window is the structural half of that promise.
A privacy label that reads "Data Not Collected" and matches the code. The SwiftData store deliberately outside the app group so no extension can read a saved contract. A widget that cannot carry the paid surface even by accident, because the snapshot builder passes "not unlocked" and the month entries are never computed. Calendar alarms that mirror the same rule set as the notifications, so the paywall applies to them without a second implementation. And a "not looked yet" that is never reported as "found nothing": before the page sweep ends, the picker claims nothing.
The everyday pivot, carried through every surface in one session. The list opened on purchases (since 1.5 it opens on All), the scan button inherits the kind from the segment it was tapped in and stops saying "scan a contract" on a purchase screen, the empty state passes the kind it already used to choose its own title, and the one entry point that inherits nothing, a document somebody shared in, keeps the question, because the share extension takes bytes and asks nothing, so no human has said what arrived. Reading the segment there would turn a shared contract into a purchase because somebody happened to be browsing purchases.
What I learned
Measure before you believe your own fix. Our language filter was a plausible idea that made the failure it addressed more likely, and only a measurement on the real document said so. The same session found that the test which was supposed to pin the ladder had silently taken a different route, because a fixture returned nil for the attachment: green, and pinning something other than what its name claimed.
Errors have to be distinguishable before they can be diagnosed. Six cases folded onto one sentence cost us two device rounds. Once each case said what it was, the next photograph from the device named the fault in one word.
A small on-device model is anchored by whatever you show it. Short positive rules that name where on the page a value lives work; long ones drift; counter-examples make it worse; every field optional, or it invents. Persuading a 3B model with more prose is the opposite of the rule, which is why we filtered the input rather than arguing with the model about the language of the document.
Ship the honest sentence instead of a silent failure. Every dead end in the app now says what happened and names the way out, and where we had a route that we had actually watched work (a screenshot of the failing page), the app says that instead of offering advice.
A name is a measurement, so change one variable. The store images are still the ones from before the rename, and deliberately so: change name, subtitle, keywords and pictures at once and a movement in the numbers tells you nothing about what caused it.
What's next for DueDog: Receipt·Warranty·Fines
The honest list, which is mostly what our own backlog says is still open.
The PDF export still contains contracts only. Purchases and the month section are blocked on a dedicated session in our shared package, because that document type only knows a contract row, and our house rule forbids changing the package casually from an app session. Rather than half-build it, we changed what the app promises: the row, the section heading and the unlock text say "contract deadlines" instead of "your deadline list". That makes the gap honest, it does not close it, and closing it is the next thing that touches money. A single record exported from its own detail screen still carries the heading "Deadline list", for the same reason and in the same session.
Fines, the parts we deliberately did not build in 1.5. Calendar export and PDF export for fines, page badges for fine vocabulary in the multi-page picker, a reminder lead of your own for a fine, and a counting mode "from the day after" (that one belongs in the shared package). And never a statement about when a fine becomes time-barred: that would be a legal statement about this case, which is the one thing the app does not make. (The "1 days left" plural bug that stood here on 04.09. is fixed in 1.5, with plural variants in all three languages and a regression guard over both string catalogs.)
Encrypted local backup. Passphrase, AES-256-GCM, out through the share sheet, with the honest price named out loud: passphrase gone, backup gone. No CloudKit, no sync, because the airplane-mode promise and the privacy label are the product. As the portfolio's second backup it belongs in the shared package, which again means its own session first.
Calendar entries that carry the receipt facts (shop, date, amount, order number as text, readable in any calendar without DueDog, plus a deep link back for the case where DueDog is there). Real attachments are not available to third-party apps, so we will not pretend otherwise. Then a store image round built for the purchase identity, held back on purpose until the rename has been measured, a seventh screenshot of the confirmation screen once the simulator fixture can fake text recognition and not just the model, and the cancellation letter generator that has been waiting in the backlog since the beginning.
Built With
- apple-foundation-models
- eventkit
- naturallanguage
- revenuecat
- storekit
- swift
- swiftdata
- swiftui
- usernotifications
- vision
- widgetkit
- xcodegen

Log in or sign up for Devpost to join the conversation.