Delivered remotely for a business based in Birmingham, United Kingdom. Names withheld by agreement.
Project Overview
Industry: Field service and multi-location operations, delivered as a subscription service.
Type of solution: A cross-platform mobile application for iOS and Android, built as the client tier over a hosted REST API. Our team's scope was the mobile application and both native project shells.
Business context: Businesses that operate across many physical sites face a persistent coordination gap. Issues at a location are noticed continuously, but the reporting path is informal — a call, a radio, a message, a note. Reports go missing, nobody can confirm afterwards whether an issue was dealt with, and the business has no visibility into how long anything takes. The client's system captures those reports; what was missing was a way for the staff who actually resolve them to receive, action and close them without being at a computer.
General users: Site and field staff who receive and resolve tickets, and an administrative role with an operator-level view of business performance.
General purpose: To move issue resolution onto the phone — pushed the moment an issue is raised, actionable in one gesture, and permanently recorded once closed.
The Business Challenge
The report has to reach a person who is moving. Site staff are on a floor, in a vehicle, between locations. A resolution tool that assumes a desk does not get used, and a notification that arrives silently in an app nobody has open is the same as no notification at all.
A tap on a notification must land somewhere useful — in every app state. A push can arrive while the app is open, while it is backgrounded, or when the app is not running at all. Each of those is a different mechanism on each platform, and the cold-start case is the hardest: the notification arrives before the application's navigation even exists. Getting two of the three right produces an app that works until the moment it matters.
Sign-in has to be trivial for staff who are not desk workers. Passwords are a support burden and a barrier for a shift-based workforce. Authentication had to be fast, memorable and require nothing the user does not already have in their hand.
Resolving a ticket has to take one gesture. If closing a ticket requires opening it, scrolling, and confirming, the queue does not get worked. The interaction had to be as fast as dismissing a message.
Closing a ticket cannot mean losing it. Operations needs to know afterwards what was resolved, what was dismissed, when, and by whom. A resolved ticket that disappears is an accountability gap, not a completed task.
Two audiences, one binary. Field staff and administrators need genuinely different applications — different screens, different data, different purpose — delivered from a single app on a single set of store listings.
Two platforms, one team, one behaviour. Push notifications, background handling and permission flows differ substantially between iOS and Android, but the product had to behave identically on both.
Our Approach
Treat notification routing as the core problem, not a finishing touch. We implemented all three notification-arrival paths explicitly — foreground, background-tap, and cold start — including the one most often skipped: on cold start the payload is captured and held in local storage, then replayed once the navigation container has mounted, so a tap from a fully closed app still lands on the right screen for the right role. Push registration, permission handling and token lifecycle are owned in one place at the root of the application rather than scattered across screens.
Build passwordless authentication around the phone the user already has. Sign-in takes a phone number with international dial-code selection, sends a one-time passcode through a hosted verification service, verifies it, and exchanges the verified identity for a session token. No password is ever created, stored or handled by the application.
Register for push at the moment of sign-in, and de-register at sign-out. The device push token is captured during authentication and submitted with the session request, so a user is reachable from the first moment they are signed in. On sign-out the token is deleted server-side rather than merely dropped locally, so a shared or reassigned device does not keep receiving another person's tickets.
Resolve the role once, then commit to it. The signed-in user's role is resolved at sign-in and again on silent session restore, and determines which entire navigation stack is mounted. Each audience gets a focused application rather than a shared one with sections hidden.
Make the primary action a swipe. The open queue exposes resolve and dismiss as per-row swipe actions, so working through a list of tickets is a sequence of single gestures. The row closes cleanly and the list reconciles against the server's response.
Keep every closed ticket, with its outcome and its owner. Resolved and dismissed tickets move to a separate history view that records which outcome occurred, when, and the name of the person who actioned it.
Let the server do the arithmetic. Volume counters and resolution-time averages are computed server-side and delivered pre-aggregated, so each screen makes a single request and the client stays a presentation layer.
Treat an unauthorised response as a real event. If the API reports that a session is no longer valid, the app does not guess or retry — it de-registers the push token, clears local session state and returns the user to sign-in from wherever they were.
Keep the client small on purpose. With a handful of screens and very little genuinely shared state, we used component-local state and local persistence rather than introducing a state management library. The result is less indirection, faster onboarding for a maintainer, and no framework ceremony around an app that does not need it.
The Solution
Passwordless phone sign-in. Phone number entry with an international dial-code picker and locale-aware formatting, one-time passcode delivery, verification, and session establishment — three steps, no password.
Silent session restore. On launch the app checks for a stored session and routes the user directly into the correct role's application, presenting the sign-in screen only when there is genuinely no session.
Staff summary dashboard. Ticket volumes across day, week, month and year, alongside a month-to-date average resolution time, giving a site's staff an immediate sense of both load and performance.
Open ticket queue. The live list of issues awaiting action, each showing the site, the issue category, an optional description where the category is open-ended, and when it was raised. Swiping a row exposes resolve and dismiss actions.
Ticket detail. A focused view of a single issue with the same resolve and dismiss actions available as full-width controls, for cases where the swipe gesture is not the right interaction.
Closed ticket history. Every actioned ticket, colour-coded and labelled by outcome, timestamped, and attributed to the person who actioned it.
Tabbed queue switching. Open and closed views sit behind a two-tab switch, with the entry tab controllable by the screen that navigates in — so completing an action can return the user directly to the history view.
Fresh-on-focus lists. Queues re-fetch whenever the screen regains focus, so returning from a detail view never shows a stale list — with the blocking loader suppressed on refresh so the screen does not flash.
Push notifications with correct routing. Delivered through Firebase Cloud Messaging, presented natively on both platforms, and routed into the correct role's stack whether the app was open, backgrounded, or not running.
Administrator dashboard. A separate application shell presenting an operator-level view of business performance across several time horizons.
Confirmed sign-out. A confirmation dialog, server-side push token removal, local session clear, and a reset to the authentication stack.
Key Features
Three-state push notification routing Foreground, background-tap and cold-start notification arrival are each handled explicitly, with the cold-start payload persisted and replayed once navigation is ready — so a tap from a fully closed app still lands on the correct screen.
Passwordless one-time-passcode authentication Phone-number sign-in with international dial-code selection and formatting, hosted passcode delivery and verification, and token-based session establishment. No password is ever created or stored.
Push token lifecycle tied to session lifecycle The device token is registered at sign-in and removed server-side at sign-out, so a device stops receiving a user's tickets the moment that user signs out.
Role-resolved application shells Two distinct navigation stacks selected from the signed-in user's role at both first sign-in and silent restore, giving each audience a focused app rather than a shared one with sections hidden.
One-gesture ticket resolution Resolve and dismiss exposed as per-row swipe actions on the open queue, with clean row closure and list reconciliation against the server response.
Retained outcome history with attribution Closed tickets are preserved in a separate view showing outcome, timestamp and the person who actioned them, so resolution is auditable rather than invisible.
Server-aggregated operational metrics Volume counters and average resolution time arrive pre-computed, keeping the client a single-request presentation layer and the aggregation logic in one place.
Automatic session invalidation handling An unauthorised API response triggers a complete, clean sign-out from any screen — token de-registration, local clear, and reset to authentication.
Consistent cross-platform behaviour Identical interaction, permission and notification behaviour across iOS and Android from one codebase, including native-side push wiring and platform-appropriate notification presentation.
Technical Architecture
Application shell. A root component that owns the entire notification lifecycle — permission request, token retrieval and persistence, foreground presentation, and tap handling in all three app states — mounting a root navigator that composes four sub-stacks: a launch stack that resolves session and role, an authentication stack, and one application stack per role.
Navigation layer. Stack-based navigation with an imperative navigation reference, so notification handlers firing outside the React component tree can still drive navigation. Role branching is centralised in exactly two places, both reading the same persisted role value.
Service layer. A flat module of promise-wrapped functions, one per API operation, each presenting a uniform contract to its calling screen: resolve with the response body regardless of the API's own success flag, reject only on transport failure. Screens branch on payload shape and always have one error case to handle.
HTTP client. A small hand-written client class owning the base URL, per-request retrieval and injection of the stored session token, request timeouts, and normalisation of transport failures into a single error shape — chosen over framework interceptors for explicitness in a codebase this size.
State and persistence. Component-local state throughout, with local device storage holding the session flag, user profile, session token and push token behind a centralised key registry and a thin storage utility. Cross-screen continuity flows through storage and navigation parameters; list freshness through focus-triggered re-fetch.
Presentation layer. A small in-house component set — text, input, button, toolbar, loader, alert and confirm dialogs — over a shared colour palette, a bundled font family and a responsive sizing helper, so screens compose from consistent primitives rather than duplicating styling.
Native layer. Platform-specific push wiring on both sides: native notification handling and Firebase initialisation on iOS, and notification channel creation with local notification presentation on Android, with a background message handler registered at the JavaScript entry point before the application mounts.
Flow: Push service → native handlers → notification router → role-resolved navigation stack → screen → service function → HTTP client with injected session token → hosted REST API
Technology Stack
| Category | Technology |
|---|---|
| Mobile framework | React Native, targeting iOS and Android from one codebase |
| Language & style | JavaScript, functional components with hooks throughout |
| Navigation | React Navigation — nested stack navigators with an imperative navigation reference |
| State management | Component-local state with device-local persistence (no external state library, by design) |
| Networking | Axios behind a custom HTTP client with token injection, timeouts and unified error handling |
| Local persistence | Asynchronous key-value device storage for session, profile and push tokens |
| Push notifications | Firebase Cloud Messaging, with native notification presentation on both platforms |
| Authentication | Passwordless one-time passcode over a hosted verification service (Twilio-backed) |
| Backend interface | Hosted REST API over HTTPS with header-based token authentication |
| List interaction | Virtualised lists with per-row swipe action rows |
| Input handling | Keyboard-aware scrolling, international dial-code picker, phone number formatting |
| Formatting | Date/time formatting and currency formatting helpers |
| Design system | In-house component primitives, shared palette, bundled font family, responsive scaling helper |
| Android build | Gradle with release configuration and code shrinking; minimal permission footprint |
| iOS build | CocoaPods workspace with native Firebase and push notification integration |
| Testing | Jest with the React Native preset |
Technical Challenges & Solutions
| Challenge | Our Approach |
|---|---|
| A notification tap must land on the right screen whether the app is open, backgrounded, or not running | All three arrival paths implemented explicitly, with the cold-start payload persisted to device storage and replayed once the navigation container has mounted — the case most implementations skip, and the one that matters most when a user opens the app because of a push. |
| Notification handlers fire outside the React component tree and cannot navigate | An imperative navigation reference held at the root, so handlers registered before or outside rendering can still drive routing into the correct role's stack. |
| Authentication had to be fast for shift-based staff without creating a password support burden | Passwordless phone-number sign-in with a hosted one-time passcode service, international dial-code handling, and token-based sessions — nothing to remember, nothing to reset, no password ever stored by the client. |
| A device must stop receiving a user's tickets the moment they sign out | Push token lifecycle bound to session lifecycle: registered as part of the sign-in request, and deleted server-side — not just locally — as part of sign-out. |
| Two audiences needed genuinely different applications from one binary | Role resolved once at sign-in and again at silent session restore, selecting which complete navigation stack is mounted, so each audience gets a focused app rather than a shared one with sections conditionally hidden. |
| Working a queue had to be faster than opening each item | Resolve and dismiss exposed as per-row swipe actions with clean row closure and server-response reconciliation, with full-width controls also available in the detail view for the same operations. |
| Returning from a detail view must never show a stale list, without a spinner flashing on every navigation | Focus-triggered re-fetch with the blocking loader suppressed when the list already holds data, so refreshes are silent and only the genuine first load blocks. |
| A session can be invalidated server-side while the user is mid-flow | Unauthorised responses handled as a first-class event from any screen: push token de-registration, local session clear, and a reset to the authentication stack rather than a silent failure or a retry loop. |
| A small app can be made needlessly hard to maintain by over-architecting it | A deliberate decision against a state management library and against framework interceptors, in favour of component-local state, local persistence and one small explicit HTTP client — less indirection, and a codebase a new maintainer can hold in their head. |
Security & Reliability
Passwordless authentication. Identity is established through possession of a phone and a one-time passcode. The application never creates, stores, transmits or handles a password.
Token-scoped sessions. The session token is held in app-private device storage and attached per request by a single client, so there is exactly one place where authentication is applied to outbound traffic.
Server-side authorisation is authoritative. The client does not trust its own cached role to gate access. When the API reports a session is no longer authorised, the app signs the user out completely rather than continuing on stale credentials.
Push token hygiene. Tokens are removed server-side at sign-out, not simply discarded on the device — important on shared or reassigned hardware, where the alternative is one person receiving another's operational alerts.
Minimal permission footprint. The Android build requests network access only. No location, camera, contacts or storage permissions are requested, keeping the app's access to the device narrowly scoped to what it actually does.
Encrypted transport. All production API traffic runs over HTTPS with platform transport security left at its secure defaults.
Bounded requests. Every request carries an explicit timeout and transport failures are normalised into a single handled error state, so an unresponsive backend surfaces as a clear message rather than an indefinitely blocked interface.
Retained outcome record. Closed tickets persist with their outcome, timestamp and the person who actioned them, so resolution is auditable after the fact.
Scalability & Performance
Server-side aggregation. Volume counters and resolution-time averages are computed on the server and delivered pre-aggregated, so each dashboard screen makes a single request and the client performs no computation over collections.
Virtualised lists. Queues render through virtualised list components, so only visible rows are mounted as history grows.
Single-request screens. Each screen maps to one API operation, keeping the request graph flat, predictable and easy to reason about under poor network conditions.
Silent refresh. Focus-triggered re-fetch keeps data current without a blocking spinner once a list is populated, so the app stays responsive during normal navigation.
Push over polling. Time-sensitive tickets arrive by push notification rather than by client polling, so the app consumes no battery or network waiting for work that has not been raised yet.
Lean build. Inline module requires for faster startup, code shrinking on Android release builds, and a small asset footprint — the app ships local vector-style icon assets and no remote imagery, so screens have nothing heavy to fetch or decode.
A deliberately small surface. The application does one job. Fewer dependencies, fewer screens and no state framework mean a smaller bundle, a faster cold start and a shorter path from a bug report to a fix.
Business Outcomes
- Issue reports reach the people who can act on them, pushed to the phone rather than waiting to be discovered.
- A notification tap always lands somewhere useful, including when the app was fully closed — the case where a user is most likely to be opening the app specifically because of the alert.
- Resolving a ticket takes a single gesture, so queues actually get worked rather than accumulating.
- Sign-in requires nothing a member of staff has to remember, removing a password support burden for a shift-based workforce.
- Closed tickets remain visible with their outcome and owner, so operations can see what was resolved, what was dismissed, and by whom.
- Staff have a live sense of load and performance, through volume and resolution-time figures presented on their own dashboard.
- Each audience gets a focused application, with role determining the entire experience rather than hiding sections of a shared one.
- The product behaves identically on iOS and Android, from a single codebase maintained by one team.
Why it worked
Small applications are where cutting corners is most tempting and most damaging. There is no architecture review to hide behind and no scale to justify the effort, so the details that decide whether the product is actually used — does the notification route correctly from a cold start, does the list refresh without flashing, does sign-out really revoke the device — get quietly skipped. They are also precisely the details a user notices within a day of installing.
Our team builds these at the same standard as large ones. On this project that meant implementing every notification arrival path rather than the two easy ones, binding push registration to session lifecycle so a signed-out device stops receiving another person's alerts, and treating an unauthorised response as a real event with a proper sign-out rather than a silent failure. It also meant knowing what not to build: no state management framework, no abstraction layers, no ceremony around an application that has a handful of screens and very little shared state. Restraint is an engineering decision, and on a small product it is usually the right one.
Our mobile teams work across React Native, native iOS and Android integration, push notification infrastructure, passwordless and token-based authentication, and API client design — with the judgement to size the solution to the problem rather than to the last project we happened to build.
Final Summary
A business operating across many physical sites has no shortage of people noticing that something is wrong. What it lacks is a reliable path from that observation to the person who can act on it, and any record afterwards that the action was taken.
Our team built the responder side of that loop as a cross-platform mobile application. Staff sign in with their phone number and a one-time passcode — no password to create, forget or reset. Tickets raised at their site arrive as push notifications that route correctly whether the app is open, backgrounded, or was not running at all, including the cold-start case where the payload is held and replayed once the app is ready. The open queue is worked with single swipe gestures, resolving or dismissing each item. Closed tickets are retained with their outcome, their timestamp and the name of the person who actioned them, and a dashboard shows staff their current volume and how long resolution is taking. An administrative role opens into an entirely separate application shell with an operator-level view of business performance.
Underneath it, the decisions that keep a small application maintainable: one HTTP client owning tokens, timeouts and error shape; a flat service layer with a uniform contract; component-local state rather than a framework; and role resolved in exactly two places. Delivered on iOS and Android from one codebase, sized to the problem rather than to the fashion.