Delivered remotely for a business based in Cork, Ireland. Names withheld by agreement.
Project Overview
Industry: Retail grocery and consumer price comparison.
Type of solution: Two fully native mobile applications — Kotlin for Android and Swift for iOS — over a shared REST API, delivering basket comparison, ordering and loyalty.
Business context: Grocery is one of the few categories where households spend continuously, notice price differences, and still have almost no practical way to act on them. Comparison sites report individual product prices, which is information without utility: a shopper does not buy one item, and no retailer is cheapest across a whole basket. The commercially useful question is different and considerably harder — for the specific things I am buying this week, where should I shop?
General users: Household grocery shoppers. Retailer-facing and administrative functionality lives in the backend platform rather than in these applications.
General purpose: To turn grocery price data into a decision a shopper can act on, and then to let them act on it without leaving the app.
The Business Challenge
Per-item price comparison is nearly useless. Knowing that one store is cheaper on milk and another on coffee does not tell anyone where to shop. Only the basket total answers the actual question.
Basket comparison invites a dishonest answer. Stores do not stock identical ranges. If unstocked products are simply omitted from a store's total, that store appears cheapest precisely because it carries less — the comparison becomes actively misleading, and shoppers discover this the moment they arrive at the till.
Products must be matched across retailers. Comparing a basket requires knowing which product at one store corresponds to which at another, across different packaging, sizes and own-brand equivalents. This data problem dominates the entire category.
The shopper is waiting. Pricing a full basket across several stores is much heavier than a single lookup, and it happens while the user watches. Slow comparison is unused comparison.
Insight has to convert into an order. A comparison that ends in a number is a research tool. Turning it into a completed order is what makes the product a business.
Two platforms, doubled cost. Fully native iOS and Android applications mean every feature is built twice, with a permanent risk of behavioural drift between them.
Search must feel instant across a large catalogue. Grocery catalogues are large and shoppers type partial, informal product names.
Our Approach
Compare baskets, not items. We built the comparison around the assembled basket as the unit, because that is the only comparison that answers a real question. It is the harder path — per-item comparison would have been trivial — but it is the difference between a curiosity and a product people use weekly.
Handle missing items explicitly and visibly. Products a store does not stock are surfaced through a dedicated flow rather than dropped from that store's total. This protects the integrity of the comparison, because a basket total is only meaningful if the shopper knows exactly what it does and does not include.
Turn the comparison into a decision. Rather than presenting a table of totals for the shopper to interpret, the result identifies the best-value store for their specific basket. The product's job is to answer the question, not to hand back the raw material for answering it.
Compute comparison server-side. With two native clients, duplicating pricing and comparison logic would guarantee divergence between platforms. All comparison computation happens once, on the server, with both applications rendering the same result set.
Give each screen its own API boundary on iOS. Rather than a single monolithic network manager, each screen owns a dedicated service class handling its own API calls, with separate handler classes owning data-source behaviour for list-driven screens. For a native codebase of this size, that separation is what keeps it maintainable.
Centralise transport on Android. A single generic HTTP layer covers every verb and body encoding — JSON, multipart and form-encoded — so no screen contains transport code and the API contract is adopted in one place.
Build discovery around location. Store selection uses mapping and location services, because relevance in grocery is a function of how far the shopper is willing to travel.
Support both appearances from the start. Light and dark presentation were built in rather than retrofitted, which is significantly cheaper than adding them to a finished interface.
The Solution
Location-based store discovery. Shoppers identify nearby stores through mapping and location services, establishing the set of retailers their basket will be compared across.
Fast product search. Type-ahead search returns results as the shopper types, accommodating the partial and informal product names people actually use.
Featured and promoted products. Curated and promoted listings give shoppers a route into the catalogue that does not require knowing what to search for.
Basket assembly. Products are added, adjusted and removed from a basket that persists across the session, with full product detail available before committing.
Multi-store basket comparison. The complete basket is priced across the selected stores, returning a per-store result set — the core capability the product exists to deliver.
Missing-item handling. Where a store does not stock an item in the basket, the shopper is told explicitly rather than seeing a total that quietly excludes it, so every comparison is like-for-like or clearly labelled as not.
A clear best-value result. The comparison resolves into an identified best-value store for that specific basket, presenting a decision rather than a dataset.
Checkout. The shopper orders from the chosen store, converting the comparison insight into a completed transaction without leaving the app.
Order history. Previous orders are retained and re-accessible, which matters disproportionately in grocery — where most of each week's basket repeats the last one.
Loyalty and rewards. A rewards programme layered over ordering, giving shoppers a reason to transact in-app rather than using the comparison and then buying elsewhere.
Account and profile management. Registration, sign-in, password management, profile details, notification handling and settings.
Key Features
Whole-basket price comparison The complete assembled basket is priced across multiple nearby stores, answering the question shoppers actually have rather than reporting individual item prices.
Explicit missing-item handling Products a store does not stock are surfaced through a dedicated flow rather than silently excluded, protecting the comparison from systematically favouring stores with the narrowest range.
Best-value result presentation The comparison resolves into an identified best-value store, giving the shopper a decision instead of a table to interpret.
Location-based store discovery Mapping and location services establish which nearby stores are relevant, since travel distance determines what a saving is actually worth.
Type-ahead product search Incremental search across a large grocery catalogue, tolerant of the partial and informal terms shoppers use.
Integrated checkout Ordering from the chosen store inside the app, converting comparison insight into a completed transaction.
Order history and repeat access Retained previous orders, reflecting how much of a weekly grocery basket repeats — turning a comparison tool into a weekly habit.
Loyalty rewards programme Rewards for in-app transactions, aligning the shopper's incentive with completing the order rather than comparing and leaving.
Fully native on both platforms Kotlin and Swift applications built natively for their respective platforms, with server-side comparison ensuring both present identical results.
Light and dark presentation Both appearances supported from the outset rather than added afterwards.
Technical Architecture
iOS client. A native Swift application organised into UI, controllers, managers, models, helpers and extensions. Each screen pairs a view controller with a dedicated service class owning that screen's API interaction, and list-driven screens have separate handler classes owning data-source behaviour — a deliberate three-way separation that keeps a large UIKit codebase maintainable.
Android client. A native Kotlin application organised by concern — UI, networking, adapters, feature-grouped models, utilities, extensions and interfaces — built with a modern Gradle configuration using the Kotlin DSL and a centralised dependency version catalog.
Networking layer. On Android, a single generic HTTP client exposes every verb and body encoding through one interface, with a typed API contract layered over it. On iOS, per-screen service classes own their own requests. Both target the same REST API with token-based authorisation.
Comparison engine. Basket comparison is computed server-side and returned to both clients as a per-store result set, so pricing logic exists once rather than being duplicated across two native codebases.
Location and mapping. Platform location services and mapping integration drive store discovery and relevance.
Media handling. Image loading with caching on both platforms, necessary for the heavily image-driven product listings a grocery catalogue requires.
Flow: Native app (iOS / Android) → Token-authenticated REST API → Server-side basket comparison across stores → Per-store result set with missing-item detail → Best-value presentation → Checkout → Order history and loyalty
Technology Stack
| Category | Technology |
|---|---|
| Android language | Kotlin |
| Android build | Gradle with Kotlin DSL and dependency version catalog |
| Android networking | Retrofit with Gson, OkHttp with logging interceptor, over a generic HTTP request layer |
| Android UI | AndroidX, Material Components, ConstraintLayout, with light and dark theme resources |
| Android media | Glide image loading and caching |
| iOS language | Swift |
| iOS architecture | Per-screen view controller + service class, with dedicated handlers for list-driven screens |
| iOS dependencies | CocoaPods (rich text labels, page control, animated image support) |
| Location & mapping | Google Play Services Maps and Location on Android; platform location services on iOS |
| API | Shared REST API with token-based authorisation |
| Comparison | Server-side basket comparison returning per-store results |
Technical Challenges & Solutions
| Challenge | Our Approach |
|---|---|
| Per-item price comparison being commercially useless | Comparison built at whole-basket level as the product's core unit — the harder engineering path, but the only one that answers the question a shopper actually has. |
| Basket totals being distorted by products a store does not stock | Missing items surfaced through an explicit dedicated flow rather than dropped from the total, so a comparison is either genuinely like-for-like or clearly labelled as not. Without this, the comparison would systematically reward the store with the poorest range. |
| Shoppers receiving numbers instead of an answer | The comparison resolves into an identified best-value store for that specific basket, presenting a decision rather than a dataset for the user to interpret. |
| Two native codebases risking divergent results | All comparison and pricing computation performed server-side and returned as a result set, so the logic exists once and both platforms are guaranteed to present the same answer. |
| A large grocery catalogue and informal search terms | Type-ahead search with incremental querying, returning results as the shopper types rather than requiring complete, correctly formed product names. |
| Screens accumulating transport and data-source logic | On iOS, each screen owns a dedicated service class and, where list-driven, a separate data-source handler; on Android, a single generic HTTP layer means no screen contains transport code. |
| Image-heavy listings on mobile connections | Caching image loaders on both platforms, so repeated browsing of a large product catalogue does not repeatedly re-fetch the same media. |
| Comparison insight not converting to revenue | Checkout, order history and a loyalty rewards programme built into the same journey, so the shopper transacts in-app rather than comparing and buying elsewhere. |
Security & Reliability
Authenticated API access. Requests carry token-based authorisation, with registration, sign-in and password management handled through the platform API.
Server-side computation. Pricing and comparison logic is held server-side rather than in the clients, so the values a shopper sees cannot be manipulated on the device and both platforms are guaranteed consistent.
Location permissions. Location access is requested for store discovery through the platform permission model on each operating system.
Data minimisation on device. The applications are presentation clients over a hosted API rather than local systems of record, so order, pricing and account data is not accumulated on the device.
Consistent transport handling. Centralised networking layers on both platforms mean authorisation, encoding and error handling are applied uniformly rather than re-implemented per screen.
Scalability & Performance
Server-side comparison. The heavy computation — pricing a full basket across several stores — happens once on the server, so client performance is a rendering concern rather than a calculation one.
Incremental search. Type-ahead querying returns bounded result sets as the shopper types, rather than loading or filtering a large catalogue on the device.
Image caching. Cached image loading on both platforms keeps repeated catalogue browsing fast and reduces data usage on mobile connections.
Efficient list rendering. Dedicated data-source handling for list-driven screens keeps large product and comparison lists smooth.
Native performance. Fully native implementations on both platforms give direct access to platform rendering, location and media capabilities without an abstraction layer.
Thin clients. With the comparison engine and catalogue held server-side, the applications remain light and their behaviour scales with backend capacity rather than device capability.
Business Outcomes
- Shoppers get an actionable answer, because comparison operates on the whole basket rather than on individual products.
- The comparison can be trusted, since unstocked items are surfaced explicitly instead of quietly improving a store's total.
- Insight converts into revenue, with checkout, order history and loyalty rewards built into the same journey rather than left to a separate channel.
- Repeat shopping is frictionless, through retained order history — significant in a category where most of each week's basket repeats the last.
- Both platforms always agree, because comparison is computed once server-side rather than implemented twice.
- Store relevance reflects reality, with location-based discovery ensuring compared stores are ones the shopper would actually visit.
- The apps feel native, because they are — with full platform integration and both light and dark presentation.
Why it worked
Price comparison products fail on integrity long before they fail on features. It is trivial to produce a basket total that makes one store look cheapest; it is considerably harder to produce one a shopper can trust after they have been to the till. That difference is entirely an engineering and product-judgement question — how unstocked items are handled, whether products are genuinely matched, whether the answer is presented as a decision or as raw data.
Our team builds for the version that survives contact with the customer. We chose basket-level comparison over the far easier per-item approach because only one of them is worth using. We made missing items an explicit, visible flow rather than a silent omission. And we kept the comparison computation server-side, because two native clients implementing the same pricing logic will diverge — and in a comparison product, two platforms disagreeing about which store is cheapest is fatal to trust.
Our teams work natively across Kotlin and Swift as well as cross-platform, and structure large native codebases so they stay maintainable — per-screen service boundaries, separated data-source handling, and centralised transport, rather than the accumulation of network code in view controllers that makes most apps of this size expensive to change.
Final Summary
Grocery shoppers know prices differ between stores and have almost no practical way to act on it. Comparison tools report individual product prices, which sounds useful and is not: nobody buys one item, and no retailer is cheapest across a full basket. The question worth answering is where to shop for this basket, this week — and answering it honestly is harder than it looks, because stores stock different ranges and a naive total rewards whichever store carries the least.
Our team built native iOS and Android applications around that harder question. Shoppers discover nearby stores by location, search a large catalogue with type-ahead, assemble a basket, and have that complete basket priced across multiple retailers. Items a store does not stock are surfaced explicitly rather than dropped from the total, so every comparison is either genuinely like-for-like or clearly flagged. The result resolves into an identified best-value store — a decision, not a spreadsheet — and the shopper can order from it immediately, with order history and loyalty rewards keeping the habit in the app.
Technically, the significant decisions were to compute comparison server-side so two native codebases could never disagree about the answer, and to structure both applications so they stay maintainable at scale — per-screen service boundaries and separated data-source handling on iOS, a single generic transport layer on Android. In a comparison product, trust is the entire proposition, and trust is what those decisions protect.