Skip to content
All work
PRODUCTION2026

Banny

A men's health recovery app whose hardest engineering sits in three native iOS extensions.

A behavioural health recovery platform built around a scored assessment that produces a personalised programme rather than a fixed one. The interesting engineering is not the Flutter app: it is the three native iOS app extensions built on Apple's Screen Time and Family Controls stack, which let the product block content at the operating-system level and lift the block on a timer the app never has to be running for.

3
Native iOS extensions bridged to a Flutter app
4
Assessment axes feeding one scoring model
6
Reminder types, each computed from programme state
3
Locales shipped at launch

The problem

Habit-recovery apps almost always fail at the same point: they can nag, but they cannot actually stop the behaviour, because an app has no authority over anything outside its own process. Apple's Screen Time stack can, but it is only reachable from native app extensions that run in their own processes, outside Flutter entirely, with no shared memory and no method channel back to Dart. The second problem is clinical rather than technical. A fixed programme is wrong for almost everyone, so the recovery plan has to be computed from an assessment, and the scoring has to be defensible rather than arbitrary.

The approach

  • Three native extensions around a Flutter app

    A DeviceActivityMonitor to end a timed block when its interval elapses, a ShieldConfiguration extension to render the block screen, and a ShieldAction extension to handle what the user does with it. All three are Swift, all three run outside the Flutter process, and they coordinate with the app through a shared App Group rather than a channel.

  • A block that survives the app being closed

    The app applies the shield and schedules a DeviceActivity interval; the operating system calls back into the monitor extension when that interval ends, and the extension clears the ManagedSettings store itself. Recovery does not depend on the app running, or on the user leaving it open.

  • A scored assessment, not a fixed programme

    A branching questionnaire across physical, mental, social and lifestyle axes feeds a scorer that sets the programme length between 14 and 60 days from a 21-day baseline, driven by factors with a stated clinical rationale rather than a number picked to look reassuring.

  • Graceful degradation by iOS version

    Every Family Controls call is gated behind an availability check, so the app still installs and runs on devices that predate the API. The shield is an enhancement on capable hardware rather than a hard requirement that shuts older phones out.

System architecture

How it is put together

  1. Assessment engine

    Branching questionnaire across four axes feeding a scorer that computes a 14 to 60 day programme from a 21-day baseline

  2. Flutter client

    Clean Architecture feature modules, generated Riverpod, go_router, Dio, freezed models and a Neo-Brutalist design system

  3. Native iOS extensions

    DeviceActivityMonitor, ShieldConfiguration and ShieldAction in Swift, coordinating with the app through a shared App Group

  4. Local persistence

    sqflite plus shared_preferences for the programme, adherence history and schedule metadata

  5. Scheduled notifications

    Timezone-aware on-device scheduling for six reminder types, each derived from programme state, with a reserved ID block per feature and deep-link payloads into the right screen

  6. Backend

    Supabase Postgres with auth and row-level security, serving both the app and the web platform

  7. Web platform

    Next.js 16 App Router marketing site, content hub and admin dashboard over the same Supabase project

  8. Revenue and measurement

    RevenueCat subscriptions with a paywall UI; one analytics facade fanning events out to PostHog and Firebase Analytics, with Crashlytics tied to the same user and all collection off outside production

Engineering decisions

The calls worth defending

Let the operating system enforce it, not the app

An app asking a user not to do something is a reminder. Apple's ManagedSettings store applied from an extension is enforcement, and it keeps working when the app is closed, backgrounded or killed. Reaching for it meant accepting three extra Swift targets, their own entitlements, their own provisioning and a manual Xcode wiring step that Flutter tooling cannot generate. That was the right trade, because without it the product is a reminder app.

An App Group instead of a method channel

The extensions run in separate processes that the Flutter engine is not part of, so there is no channel to call. State crosses the boundary through a shared App Group's user defaults: the app writes the shield mode and end time, the extension reads and clears them. It is a smaller contract than a channel would be, and it is the only one that works when the app is not running.

Availability-gated, not deployment-target-raised

Family Controls needs iOS 16, but raising the deployment target would have cut off working devices for a feature most sessions never touch. Every call is gated behind an availability check instead, so the app installs on older hardware and the shield simply is not offered there.

Reminders derived from state, not sent on a timer

Every reminder is recomputed when the state behind it changes. The streak reminder is cancelled and rescheduled each time a day's adherence is written, including a backfilled day, so it always names the streak that is actually at risk and disappears when there is nothing left to protect. Reassessment reminders land at 30, 60 and 90 per cent of the user's own computed programme, not on fixed dates. It is all scheduled on the device, so none of it needs a server, a push token or the app to be open.

Compute the programme, do not pick it

A fixed 30-day plan is wrong for almost everyone and obviously arbitrary to anyone who thinks about it. Deriving the length from a scored assessment means the number a user sees can be explained, and it means the model can be revised against outcomes instead of defended on instinct.

Interface

What shipped

  • Banny: The recovery road

    The recovery road

    A programme whose length is computed from the assessment, not fixed

  • Banny: Personalised activities

    Personalised activities

    Daily practices drawn from the scored profile rather than a static list

  • Banny: Phases with a rationale

    Phases with a rationale

    Each phase states what it is for and what changes in it

  • Banny: Responding to an urge

    Responding to an urge

    The in-the-moment path, which is where the Screen Time shield is applied

  • Banny: Guided chat

    Guided chat

    Context-aware guidance against the user's own programme state

In depth

Why this one is worth reading

Banny is a consumer health app in a category most engineers never touch, and the temptation is to describe the product. The part worth an interview is narrower than that: it is what happens when a Flutter app needs authority it cannot have from Dart.

The process boundary

Apple's Screen Time stack is not an API you call. It is a set of app extensions the system loads into their own processes, at times of its choosing, with your app nowhere in the picture. Three of them matter here.

DeviceActivityMonitor is called when a scheduled interval ends. Banny uses it to lift a timed block: the app applies the shield and schedules the interval, and when the interval elapses the system wakes the extension, which clears the ManagedSettingsStore itself. The app does not need to be running, backgrounded, or even installed in memory.

ShieldConfiguration renders what a user sees when they open a blocked app.

ShieldAction decides what happens when they press a button on that screen.

None of these can talk to Flutter. There is no engine in those processes and no method channel to reach. So the contract between the app and its extensions is a shared App Group: the app writes the shield mode and the end timestamp into group user defaults, and the extensions read and clear them. That is a deliberately small surface, and it is the only one that survives the app not existing at the moment the work needs to happen.

Availability, not deployment target

Family Controls needs iOS 16. Raising the project's deployment target to match would have been the quick fix and would have shut out every device below it, for a capability most sessions never invoke.

Every Family Controls call is gated behind an availability check instead. The app compiles and installs on the older target, and on a device that cannot enforce a shield the feature simply is not offered. The rest of the product is unaffected.

The assessment is the product

Recovery programmes that hand everyone the same thirty days are wrong for almost everyone, and visibly arbitrary to anyone who thinks about it for a second.

Banny's onboarding is a branching questionnaire across four axes: physical, mental, social and lifestyle. Its answers feed a scorer that sets the programme length anywhere from fourteen to sixty days against a twenty-one day baseline, weighted by factors with a stated rationale. The programme then moves through phases with different emphases rather than repeating the same daily prompt.

The engineering point is that the number is computed, which means it can be explained to a user and revised against outcomes. A hardcoded thirty can only be defended.

Two codebases, one Supabase project

The Flutter app and a Next.js 16 platform share one Supabase Postgres project with auth and row-level security. The web side carries the marketing site, the content hub and an admin dashboard; the app carries the programme itself. Keeping one database behind both means content written once appears in both places, and means there is no second source of truth to drift.

Reminders that know where the user is

A recovery programme lives or dies on whether people come back on day nine, and a daily "don't forget!" is how an app gets its notifications switched off. So Banny has no generic reminder. It has six specific ones, and each is computed from the user's own state.

Daily pulse, weekly check-in and exercise reminders repeat at the times the user chose, in their local timezone, with the recurrence stored as a cron-style expression so the schedule can be read back and shown rather than lost inside the plugin.

The streak reminder is rebuilt every time a day's adherence is recorded. It fires at seven the next evening, names the exact streak that will break at midnight, and changes its wording as the streak grows. When the streak is zero it is cancelled, because there is nothing to protect.

Reassessment reminders fall at 30, 60 and 90 per cent of the programme the assessment computed, so a fourteen-day plan and a sixty-day plan each get their checkpoints at the right moments, and a checkpoint already completed is never scheduled again.

The trial-end reminder tells the user the day before a free trial converts. For a one-day trial, where "the day before" does not exist, it fires two hours after the trial starts instead. Telling people before they are charged costs a few conversions and buys a lot of trust, and trust is the product in this category.

Underneath, each feature owns a reserved block of notification IDs so one feature can never cancel another's reminder, schedule metadata is persisted so the app can show what is pending after a restart, every payload deep-links to the screen the reminder is about, and ending a session clears every pending reminder.

Measurement

Every analytics event goes through one facade that fans out to PostHog and Firebase Analytics, with Crashlytics attached to the same user identifier. Collection is switched off entirely in the development flavour, so test sessions never pollute the funnel the product decisions are made from.

Around that: RevenueCat for subscriptions and the paywall, Codemagic for builds and store upload, and three locales at launch.