Home About Skills Experience Projects Certifications GitHub ↗ Hire me
← Back to Portfolio
SportyMate icon
Company Project · Sports Booking Marketplace · Android & iOS · Arabic/English · Octopus Software Solutions · Jun 2025 – Aug 2025

SportyMate

Overview

SportyMate is a two-sided sports booking marketplace for Android and iOS, built for players who want to find and book training and for trainers who want to sell it. Players browse sports categories, clubs and trainers, request a session on a trainer's published schedule, pay for it once the trainer confirms, and check in on the day with a QR scan. Trainers publish their own availability, work through incoming requests, connect their own payment account, and track what they've earned.

Everything is bilingual Arabic/English and RTL-native, and the same binary renders two genuinely different products: the role a user signs up under decides their tabs, their home screen, and which half of the API they talk to. I built and shipped the Flutter client end to end, including the networking layer, the offline-warm caching strategy, and the Android CI/CD pipeline.

2-in-1Player and trainer products from a single binary
AR / EN~500 localized strings, RTL-native throughout
CI/CDEvery push builds and distributes an APK automatically
Core Capabilities

What the app does

🔀
Two Products, One Build

Player and trainer get different tabs, different home screens and different API endpoints from the same binary, resolved from the cached role before the first frame renders.

📅
Trainer-Published Sessions

Trainers create their own sessions against a calendar with date, time range, sport and pricing, and manage their upcoming schedule in one place.

📩
Request, Accept, Reject

Players request a session with a trainer or a club; trainers work through pending requests and accept or reject each one, and the player's list reflects the outcome.

💳
Paymob Checkout & Trainer Payouts

Paying for a confirmed booking returns a hosted Paymob checkout URL, and trainers link their own merchant credentials plus a bank account or InstaPay handle to get paid out.

📷
QR Attendance Check-In

Trainers scan the booking's QR code on the day to verify attendance, with haptic feedback and explicit success and failure dialogs.

🔎
Debounced Paginated Search

Search across trainers, clubs, sports and booking history — debounced as you type, loaded page by page, and guarded against out-of-order responses.

🔐
Full Account Lifecycle

Separate player and trainer sign-up (trainers upload certificates and an intro video at registration), OTP email verification with resend, password reset and change, silent token refresh on 401, logout and account deletion.

🎬
Rich Trainer Profiles

An intro work video that plays from a YouTube link, a direct video URL or a locally picked file, plus certificates rendered in an in-app PDF viewer.

💰
Earnings & Booking History

Trainers track their earnings and completed sessions; players keep a full history of past, upcoming and cancelled bookings.

🌍
Arabic & English, RTL-Native

Around 500 localized strings, direction-aware padding and positioning throughout, RTL-aware price formatting, and server error messages translated into Arabic.

⚡
Warm Start From Disk

Home, bookings, club details and earnings rehydrate from the last successful response on launch, then revalidate silently in the background.

Architecture

How it's built

SportyMate is organised feature-first — each of the eighteen features owns its own `data` (models + repository), `logic` (cubit + Freezed state) and `ui` folders, over a shared `core` layer holding networking, DI, routing, theming, localization and the widget library.

State management is Bloc's Cubit layer via flutter_bloc — no events anywhere in the codebase. Of the 36 cubits, 20 are plain Cubits for write and one-shot flows, and 16 are HydratedCubits deliberately reserved for the read-heavy screens (both home variants, clubs, trainers, booking lists, earnings, location) so those render from disk on cold start and revalidate in the background. get_it wires repositories and cubits, and the whole graph is torn down and rebuilt on logout. Networking is a Retrofit client over a single Dio instance with a layered interceptor stack: a VPN/HTML guard first, then auth token injection and 401 refresh-and-retry, then logging in debug. Every repository returns a Freezed `ApiResult` union, so a cubit handles success and failure exhaustively rather than catching exceptions.

Startup is staged behind a splash bootstrap: Firebase and a performance trace come up first, then HydratedBloc storage, cache reconciliation, DI, and finally the cached token and role, which decide whether the app opens on onboarding, role selection or the main shell. Delivery is automated — every push to `development` builds a release APK on GitHub Actions and ships it to Firebase App Distribution through Fastlane, with a QR code and release notes posted to Telegram.

Splash bootstrap starts Firebase and a startup performance trace→HydratedBloc storage, cache reconciliation, then the get_it graph→Cached token + role route to onboarding, role selection or the shell→Role picks the tab set — player tabs or trainer tabs, one IndexedStack→Cubit → Repository → Retrofit over Dio (VPN/HTML guard → auth → retry)→Repository returns a Freezed ApiResult the cubit maps to state→HydratedCubits rehydrate last-good data, then revalidate silently
Walkthrough

See it in action

Splash Screen
Splash Screen
OnBoarding 1
OnBoarding 1
OnBoarding 2
OnBoarding 2
OnBoarding 3
OnBoarding 3
OnBoarding 4
OnBoarding 4
Choose Role
Choose Role
Sporty Mate Home
Sporty Mate Home
All Categories
All Categories
Club Details
Club Details
Request Details
Request Details
Home Coach
Home Coach
All Clubs
All Clubs
Create Session
Create Session
Request Details For Coach
Request Details For Coach
Requests Screen Coach
Requests Screen Coach
Coach Profile
Coach Profile
Problems Solved

Real engineering challenges

Network Reliability
Requests made behind a VPN came back as HTML, not JSON

Users on a VPN or proxy were being served the host's protection challenge — an HTML page returned with a success status — instead of the expected JSON. Retrofit's deserializer threw a parse error on it, so every screen surfaced the same meaningless failure. I added a Dio guard interceptor that inspects the content type and the response prefix and raises a typed HtmlChallengeException, alongside a pre-flight proxy/VPN check that cancels the request with its own typed exception. Both map to dedicated error codes with Arabic and English messages, so the user is told what actually happened. The check caches its verdict for 15 minutes, times out in seconds, and fails open — a reachability service should never become a hard dependency for the app.

API Integration
The same field arrived under four different names across endpoints

The backend was inconsistent about response shape: `message` vs `Message`, `success` vs `Success` vs `IsSuccess`, `checkout_url` vs `checkoutUrl` vs `redirectUrl`, `payment_id` vs `paymentId` vs `Id` — sometimes at the top level, sometimes nested under `data`, sometimes as a Laravel 422 validation map, occasionally as a bare string or a list. Rather than special-case it at every call site, I put a normalization pass in front of parsing that aliases each known variant into one canonical shape, with a dedicated path that lifts the first field error out of a 422 payload. A key-normalizing translator sits on top: it matches a server message exactly, case-insensitively, or as a slugified key, so English-only API errors reach Arabic users in Arabic.

Cross-Platform Storage
Reinstalling the app left users holding a token the server had forgotten

The two platforms break stored sessions in opposite directions. The iOS Keychain survives an uninstall, so a fresh install read back an auth token whose session was long gone; Android auto-backup does the mirror image, restoring SharedPreferences while secure storage comes back empty. Either way the app booted straight into a signed-in state that immediately failed. The fix writes a marker into both stores and compares them on cold start — a mismatch in either direction means a reinstall or a restore, so both stores are cleared and re-seeded. It runs before dependency injection, so nothing downstream ever sees a half-restored session.

Async Correctness
Fast typing made search results arrive out of order

Search is debounced and paginated across trainers, clubs, sports and booking history, which opened two races: a slow response for an earlier query could land after a newer one and overwrite it, and a 'load more' page could append onto a query the user had already changed. Each search cubit now snapshots its query and page at call time and carries a monotonically increasing token, and any response whose token or query no longer matches the current one is dropped instead of emitted. The same guard covers background refresh, where a silent refresh also deep-compares the new payload against the cached one so an unchanged response doesn't re-emit and rebuild the list.

State Persistence
Caching screens to disk made logout leak the previous session

Sixteen of the app's cubits are HydratedCubits, so home, bookings, club details and earnings render from the last good payload on cold start instead of a spinner. The trade-off is that the same persistence outlives the session it belongs to: logging out — or signing back in under the other role — left the previous user's data both on disk and in long-lived singletons. Logout now tears down and rebuilds the entire get_it graph and clears both the preference and secure stores while deliberately preserving the chosen language, and account deletion restarts the widget tree outright via Phoenix, so no screen can rebuild from a stale cache.

Technologies Employed

Built with

Flutterflutter_bloc / CubitHydratedBlocFreezedget_itDio + Retrofitjson_serializableflutter_secure_storageshared_preferencesPaymob / InstaPaymobile_scanner (QR)geolocator / geocodingintl (Arabic / English)flutter_screenutilFirebase Analytics & PerformanceSentrySyncfusion (PDF & calendar)video_player / Chewieflutter_phoenixFastlane + GitHub Actions
Screenshot preview