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
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, with scheduled local notifications driving the daily programme

  5. Backend

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

  6. Web platform

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

  7. Revenue and measurement

    RevenueCat subscriptions with a paywall UI, PostHog product analytics, Firebase Analytics and Crashlytics

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.

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.

Around that: RevenueCat for subscriptions and the paywall, PostHog for product analytics, Firebase for analytics and Crashlytics, scheduled local notifications for the daily programme, and three locales at launch.