Delivered remotely for a business based in Leeds, United Kingdom. Names withheld by agreement.
Project Overview
Industry: Consumer mobile — social gifting, digital greetings and messaging.
Type of solution: A cross-platform consumer mobile application built in Flutter, operating as a client over a bespoke REST API with cloud object storage for catalogue imagery and App Store in-app purchase for monetisation.
Business context: Digital greetings have almost entirely replaced physical ones, and in doing so they lost the moment that made the physical version worth sending. A card arrives in an envelope. A gift arrives wrapped. A photograph in a messaging thread arrives already open. The product exists to restore concealment and reveal to a digital send — and to build a sustainable consumer business around it through purchasable designs and tiered subscriptions.
General users: Senders, who create and send wrapped gifts; recipients, who may open a received gift with no account and without having previously installed the app; and subscribers on two tiers, the higher of which may upload their own wrapping designs.
General purpose: To make a digital send feel like receiving something wrapped — and to make the act of opening it a deliberate, tactile, few-seconds-long experience rather than an instant one.
The Business Challenge
The reveal has to feel physical, or there is no product. Everything else in the application is scaffolding around a few seconds of interaction. If scratching does not feel like scratching — if the gesture is laggy, the threshold arbitrary, the sound out of step with the finger — the entire premise collapses. This is not a feature to be scheduled alongside others; it is the thing being built.
Two visual layers have to become one image. A wrapper design and a freehand drawing over it are separate things while the user is working, and must become a single flat image the moment it is sent — composited faithfully, at a resolution that survives being viewed full-screen on a modern phone.
Photographs arrive in the wrong orientation. Camera images carry orientation metadata that is applied inconsistently across the pipeline. Without correction, a meaningful proportion of wrapped gifts would simply be sideways — a defect the user reads as "this app is broken", not "this is an EXIF issue".
A recipient may have no account and no app. The person opening a gift is, by definition, someone the sender wants to reach — not an existing user. The path from a link in a text message to a full-screen reveal has to work for a stranger, on a cold start, without a login wall in front of the moment the product exists to deliver.
Monetisation has to work on the first send. Consumer greetings apps have unforgiving retention economics. The product needed both impulse purchase of individual designs and recurring subscription, with restore, cancellation and history handled correctly — and with purchases verified against the server rather than trusted from the device.
Creators, not just consumers. A higher subscription tier lets users upload their own wrapping designs, which turns a catalogue into a platform and turns a subset of subscribers into content contributors — with its own upload, listing and deletion surface.
One team, two platforms, a consumer schedule. A product whose value is a single interaction cannot afford to build that interaction twice, in two languages, and keep the two in step.
Our Approach
Build once in Flutter, and mean it. The application is a single Dart codebase serving both mobile platforms, with build flavors expressed consistently at four levels — separate Dart entrypoints, a settings class hierarchy, platform build schemes and Gradle product flavors — so a staging build can never be mistaken for a production one.
Use native platform views where they earn their place, then retire them. The scratch and draw interactions shipped first as embedded native views registered through the Flutter plugin registrar, because that was the fastest route to an interaction that felt right. Both were subsequently reimplemented in pure Dart, removing the per-platform maintenance liability entirely. Reaching for native and then deliberately coming back off it is the correct arc, and comparatively rare — most teams reach for native and stay there.
Composite on the device, not on the server. The wrapper layer and the drawing layer are rasterised directly from the live widget tree at high pixel ratio, decoded, and alpha-composited into a single image before upload. This keeps the most computationally expensive operation in the product on the device that initiated it, so send volume scales with users rather than with infrastructure.
Correct image orientation explicitly. Camera output is inspected for orientation metadata and physically rotated before it enters the compositing pipeline, rather than hoping the rendering layer applies it consistently.
Make deep links navigate, not just launch. The native host layer parses an inbound universal link, extracts its parameters, and drives the Flutter application to a specific screen through a method channel and a globally held navigator reference — so a link opens the reveal, not the home screen. A legacy URL scheme was kept alongside it as a documented fallback during the transition.
Let recipients in without an account. The message-retrieval path is written to work with or without an authenticated session, so the reveal is reachable by someone who has never signed up.
Wire commerce properly, once. Store connection lifecycle, product and subscription listing, purchase and error streams, transaction finishing, pending-transaction clearing, restore and history are all handled explicitly, with receipt verification performed against the backend.
Migrate state management incrementally. The application moved from ad-hoc singleton services toward a stream-based BLoC architecture resolved through a service locator, progressively rather than in a rewrite — new surfaces built on the new pattern, existing ones migrated as they were touched.
The Solution
Occasion-based catalogue. Wrapping designs organised into themed occasion categories, with thumbnail and full-size imagery served from cloud object storage and cached on the device, so browsing a large catalogue stays fast and cheap.
In-app capture. The gift photograph is taken with the device camera at high resolution, with front and rear camera switching, or selected from the photo library — with orientation corrected before use.
Wrapper personalisation. The sender draws over the chosen wrapper with pen, shapes and text, choosing stroke thickness, stroke colour and fill colour through dedicated colour pickers, working with a draggable tool palette that gets out of the way when the canvas needs the space.
On-device compositing. The decorated wrapper is flattened with the drawing layer into a single high-resolution image at the moment of sending.
Send by any channel. The finished gift is delivered as a link over SMS, over email with a formatted HTML body, or through the native share sheet with the image attached — meeting recipients wherever they already are.
Scratch to reveal. The recipient opens the link, lands directly on the wrapped gift, and scratches the wrapper away with their finger. A paper-tearing sound plays as they scratch; past a completion threshold the wrapper clears and the gift is revealed. The revealed image can be saved to the photo library.
Open without an account. A recipient with no account and no prior installation can reach and open a gift sent to them.
In-app purchase and subscription. Individual designs can be purchased outright, or unlocked through tiered auto-renewing monthly subscriptions, with purchases verified server-side, and restore, history and cancellation handled in the account area.
User-uploaded designs. Subscribers on the higher tier upload their own wrapping designs, browse what they have created, select multiple designs for deletion, and send gifts wrapped in their own artwork.
Account management. Profile update, subscription status and management, restore purchases, cancellation, policy and terms access, and sign-out.
Password reset by link. A reset arrives as a link, opens the application directly on the reset screen, and completes in-app.
Key Features
Single-codebase cross-platform delivery One Dart codebase serving both mobile platforms, with build flavors expressed consistently across Dart entrypoints, application settings, platform build schemes and Gradle product flavors.
Scratch-to-reveal interaction with synchronised audio Continuous gesture-driven erasure of the wrapper layer with a completion threshold and paper-tearing sound feedback — first delivered as an embedded native platform view, then reimplemented in pure Dart.
On-device two-layer image compositing Wrapper and freehand drawing rasterised from the live widget tree at high pixel ratio and alpha-composited into a single image before upload, keeping the heaviest operation off the backend.
Freehand annotation with a draggable tool palette Pen, shapes and text with stroke thickness and colour control, over a floating palette that can be moved or dismissed so it never fights the canvas for space.
EXIF orientation correction Camera output inspected and physically rotated before entering the compositing pipeline, eliminating an entire class of "the app is broken" defects.
Deep-link navigation into a specific screen Universal links parsed in the native host layer and forwarded over a method channel to a globally held navigator reference, so an inbound link opens the reveal from a cold start rather than the home screen — with a legacy URL scheme retained as a fallback.
Account-free recipient path Message retrieval that functions with or without an authenticated session, so a recipient can open a gift without signing up first.
Complete in-app purchase and subscription lifecycle Store connection, product and subscription listing, purchase and error streams, transaction finishing, pending-transaction clearing, restore, history and cancellation — with server-side receipt verification.
User-generated wrapping designs Upload, listing, multi-select deletion and use of subscriber-created designs, gated to a higher subscription tier.
Stream-based state architecture with a service locator An incremental migration from singleton services to stream-driven state objects resolved through a service locator, adopted surface by surface rather than as a rewrite.
Technical Architecture
Presentation layer. Screens composed as Flutter widgets over a static named-route map, with route builders that unpack navigation arguments and resolve catalogue objects by identifier at navigation time. Reactive surfaces consume state through stream builders.
State layer. Stream-based state objects with explicit event sinks, state streams, an event-to-state dispatcher and deterministic disposal — implemented directly on Dart's asynchronous primitives rather than through a framework package — resolved through a service locator, alongside an older set of singleton services still owning session, catalogue and purchase state during an in-progress migration.
Service layer. A single hand-written REST client wrapping every backend call in one uniform response envelope carrying success, status, error and decoded payload, so every screen handles success and both classes of failure through one shape. Alongside it sit session management, dialog and progress presentation, camera coordination, navigation, and application lifecycle handling.
Media pipeline. Camera and gallery capture, orientation correction, widget-tree rasterisation, decode, alpha compositing, encode, temporary-file staging, multipart upload, and save-to-library on the receiving side.
Native host layer. A platform-native application delegate registering Flutter platform-view factories, exposing bidirectional method channels and an event channel, handling universal links and a legacy URL scheme, and forwarding parsed link parameters into the Dart layer for navigation.
Persistence layer. Key-value preference storage for session and first-run state, and the filesystem for staged image work.
Backend integration. A bespoke REST API for identity, catalogue, messages, purchases and user uploads; cloud object storage for catalogue imagery in thumbnail and full-size variants; and platform in-app purchase with server-side receipt verification.
Flow: Camera or library capture → orientation correction → wrapper selection and annotation → widget-tree rasterisation and alpha compositing → multipart upload to REST API → link delivery by SMS, email or share sheet → universal link parsed natively → method channel to global navigator → scratch-to-reveal with audio → save to library
Technology Stack
| Category | Technology |
|---|---|
| Application framework | Flutter, single codebase for both mobile platforms |
| Language | Dart (pre-null-safety SDK generation) |
| State management | Hand-rolled BLoC on Dart stream controllers, with get_it service locator and rxdart; singleton services retained during migration |
| Routing | Static named-route map with argument unpacking, plus a global navigator key for navigation outside the widget tree |
| Backend integration | Bespoke REST API over package:http with a uniform response envelope; multipart upload for imagery |
| Native interop | Platform-view factories, bidirectional method channels and an event channel via a native application delegate |
| Deep linking | Universal links via associated domains, with a legacy custom URL scheme as fallback |
| Camera & media | camera, image_picker, image, exif, photo_view, image_downloader, cached_network_image, flutter_svg |
| Reveal interaction | Dart scratch-erasure with completion threshold; assets_audio_player for synchronised audio |
| Commerce | Platform in-app purchase and auto-renewing subscriptions with server-side receipt verification |
| Sharing | SMS, mail composer, native share sheet and URL launcher integrations |
| Local persistence | shared_preferences for session and flags; path_provider filesystem staging |
| UI components | Carousel, colour pickers, value pickers, toasts, HTML rendering, device and package info |
| Object storage | Cloud object storage serving thumbnail and full-size catalogue imagery |
| Build configuration | Dual Dart entrypoints, settings class hierarchy, platform build schemes and Gradle product flavors |
Technical Challenges & Solutions
| Challenge | Our Approach |
|---|---|
| A reveal interaction that has to feel physical, or the product has no reason to exist | Delivered first as an embedded native platform view registered through the Flutter plugin registrar to get the feel right quickly, then reimplemented in pure Dart with a completion threshold and synchronised paper-tearing audio — removing the per-platform maintenance liability once the interaction was proven. |
| Two independent visual layers that must become one image at send time | Both layers rasterised directly from the live widget tree at high pixel ratio, decoded, and alpha-composited — one opaque, one blended — into a single encoded image, staged to a temporary file rather than held in memory. |
| Camera photographs arriving in the wrong orientation | Orientation metadata read explicitly and the image physically rotated before it enters the compositing pipeline, rather than relying on the rendering layer to apply it consistently across capture paths and devices. |
| A link must open one specific screen from a cold start, before any widget tree exists | The native host layer parses the inbound universal link and forwards its parameters over a method channel to a navigator reference held globally, outside the widget tree, so navigation is possible at the moment the link arrives. A legacy URL scheme was retained alongside during the transition. |
| Recipients who have no account and may not have the app | The message-retrieval path is written to operate with or without an authenticated session, so the reveal — the entire point of the product — sits in front of the sign-up wall rather than behind it. |
| A full purchase and subscription lifecycle with no room for error | Store connection, product and subscription listing, purchase and error streams, transaction finishing, pending-transaction clearing, restore and history handled explicitly, with receipts verified against the backend rather than trusted from the device. |
| Compositing cost on a consumer device fleet | Rasterisation and encoding staged through the filesystem rather than memory, imagery served in thumbnail and full-size variants with client-side caching, and audio bundled locally — keeping the peak-memory path narrow on older hardware. |
| Modernising state management without stopping delivery | An incremental migration: new surfaces built on stream-based state objects resolved through a service locator, existing singleton services migrated as they were touched, so architecture improved continuously instead of through a rewrite. |
| Staging and production builds being confused by developers | Build flavors expressed consistently at four levels — Dart entrypoints, settings hierarchy, platform build schemes and Gradle product flavors — with IDE run configurations deliberately version-controlled after their absence caused real build divergence between machines. |
Security & Reliability
Token-based authentication. Registration, sign-in, sign-out, session refresh and link-driven password reset, with a session token presented on authenticated calls and credentials never retained on the device.
Server-verified purchases. In-app purchase receipts are submitted to the backend for verification rather than entitlement being granted on the strength of a device-local success callback.
Deliberate anonymous access path. Recipient access to a shared gift is an explicit design decision with a distinct, deliberately unauthenticated code path — not an authentication gap. Gifts are addressed by opaque generated identifiers.
Uniform failure handling. Every backend call returns one response envelope distinguishing success, server-side failure and client-side failure, so no call site can silently ignore an error shape it did not anticipate, and request timeouts are handled centrally.
Deterministic resource disposal. Stream controllers, audio players, camera controllers and subscriptions are explicitly disposed, which matters on a screen that holds a camera, an audio player and multiple streams at once.
Environment separation. Staging and production are separated at four independent levels of the build, so an environment cannot be selected accidentally in one place and correctly in another.
Instrumented diagnostics. Consistent logging across services, screens and the native bridge, so an issue in the media or link-handling path can be traced across the platform boundary.
Scalability & Performance
Compositing on the device. The most expensive operation in the product — rasterising and flattening two full-screen layers — runs on the sending device. Send volume therefore scales with the user base rather than with backend capacity.
Thumbnail and full-size image variants. Catalogue browsing pulls thumbnails; full-size assets load only when a design is actually used, so a growing catalogue does not translate into a slower or more expensive browse.
Client-side image caching. Remote imagery is cached on the device, so repeat browsing of the catalogue costs neither bandwidth nor latency.
Thin client, self-contained messages. Sent gifts are addressed by opaque identifiers and are self-contained, so retrieval is a single direct lookup with no session or relationship traversal required.
Filesystem staging for large images. High-resolution intermediate images are staged through the filesystem rather than held in memory, keeping peak memory bounded on older devices.
Broadcast streams for shared state. Multiple widgets observe the same state stream without duplicated work or redundant rebuilds.
Bundled audio assets. Reveal audio is packaged with the application rather than streamed, so the sound lands in step with the gesture regardless of network conditions.
Business Outcomes
- The reveal moment was successfully translated to a phone screen, with a gesture-driven, audio-accompanied unwrapping that a digital greeting cannot otherwise deliver.
- One codebase serves both mobile platforms, so the interaction the product depends on was built, tuned and maintained once rather than twice.
- Native complexity was retired rather than accumulated — platform-specific view code was replaced with cross-platform implementations once the interaction was proven, reducing long-term maintenance surface.
- Recipients open gifts without signing up, removing the friction between a link arriving and the product's core moment being experienced.
- Links open the right screen, so a gift sent by message lands the recipient in the reveal rather than on a home screen they have to navigate away from.
- The product monetises through both one-off purchase and recurring subscription, with restore, cancellation and history handled properly and purchases verified server-side.
- Subscribers became contributors, with a higher tier turning users into creators of wrapping designs and the catalogue into a platform.
- Architecture improved without a rewrite, through an incremental migration to stream-based state that ran alongside continuous feature delivery.
Why it worked
Consumer products live or die on a single interaction, and that interaction is almost never the one with the most tickets against it. Here it was a few seconds of scratching a screen — something that either feels like unwrapping or feels like nothing at all. There is no specification that captures the difference. It has to be built, felt, and rebuilt.
Our team builds for that. We shipped the interaction natively first because that was the fastest way to know whether it worked, and then we did the harder and less visible thing: we replaced it with a cross-platform implementation once it was proven, so the team would not be maintaining two versions of the product's most important feature forever. We treated image orientation as a real problem rather than an edge case, because a sideways photograph reads to a user as a broken app. We made the deep link navigate to the exact screen from a cold start, because a link that opens the home screen has wasted the send. And we left the recipient path unauthenticated on purpose, because a sign-up wall in front of a gift someone sent you is a product failure dressed up as a growth tactic.
Our teams work across Flutter and Dart, native platform-channel and platform-view integration, on-device media pipelines, deep linking and universal links, in-app purchase and subscription lifecycles, and REST API integration — with the judgement to know which parts of a consumer product must feel right before anything else is worth building.
Final Summary
A photograph sent in a message arrives already open. That is convenient, and it is the reason digital greetings feel like less than the physical ones they replaced. This product's entire premise is to put the wrapping back: conceal the gift, make the recipient work briefly to open it, and give them the sound and the resistance of paper tearing while they do.
Our team built it as a single Flutter codebase serving both mobile platforms. The sender photographs a gift, corrects for camera orientation automatically, chooses a wrapping design from an occasion catalogue, draws over it freehand with a movable tool palette, and sends it — at which point the wrapper and the drawing are rasterised from the live widget tree and flattened into one image on the device itself. It travels as a link by text, email or share sheet. When the recipient taps it, the native host layer parses the link and drives the application straight to the reveal, from a cold start, with no account required. They scratch, it tears, the gift appears, and they can keep it.
Around that sit the things a consumer business needs: a purchasable catalogue with server-verified in-app purchases and tiered auto-renewing subscriptions, a higher tier that lets subscribers upload and manage their own wrapping designs, account and subscription management, and link-driven password reset. Underneath, the architecture was migrated incrementally from singleton services to stream-based state without stopping delivery, and native platform views were deliberately retired in favour of cross-platform implementations once they had proven the interaction. The result is a product where the engineering effort went precisely where the value was — into a few seconds of unwrapping.