Building BaliHani — A Craftsmen Discovery App for Morocco
How I built a Flutter app connecting craftsmen with clients across Morocco, from real-time booking to multi-language support, and the unique challenges of building for the Moroccan market.
BaliHani connects clients with skilled craftsmen (plumbers, electricians, carpenters) across Morocco. Built with Flutter, FastAPI, and MongoDB, it was my first product built specifically for the Moroccan market. The technical and cultural challenges were both fascinating.
The Problem
Finding a skilled craftsman in Morocco — a plumber, an electrician, a carpenter — is frustratingly word-of-mouth. People ask neighbors, call relatives, or post in WhatsApp groups. If you're new to a city, you're stuck.
BaliHani built the directory that didn't exist: verified craftsmen, ratings, real service areas, and a booking system that worked with how Moroccans actually communicate.
Technical Architecture
Flutter App
│
├── FastAPI (Python) ─── MongoDB
│ │
│ ├── Authentication (JWT + Refresh Tokens)
│ ├── Search (MongoDB Atlas Search)
│ ├── Booking Engine
│ └── Notifications (Firebase FCM)
│
└── Firebase (Real-time chat, FCM)
The Search Problem
Craftsmen have service areas — a plumber in Casablanca doesn't serve Rabat. The search had to filter by:
- Craft category (plumber, electrician, etc.)
- Geographic proximity
- Availability
- Rating
MongoDB's geospatial queries handled proximity efficiently:
@router.get("/search")
async def search_craftsmen(
category: str,
lat: float,
lng: float,
radius_km: float = 20,
available_date: Optional[date] = None,
):
pipeline = [
{
"$geoNear": {
"near": {"type": "Point", "coordinates": [lng, lat]},
"distanceField": "distance",
"maxDistance": radius_km * 1000, # meters
"spherical": True,
"query": {"category": category, "active": True},
}
},
{"$sort": {"rating_avg": -1, "distance": 1}},
{"$limit": 30},
]
if available_date:
pipeline.insert(1, {
"$match": {
"unavailable_dates": {"$not": {"$elemMatch": {"$eq": available_date}}}
}
})
results = await db.craftsmen.aggregate(pipeline).to_list(None)
return resultsThe $geoNear stage requires a 2dsphere index on the location field:
await db.craftsmen.create_index([("location", "2dsphere")])Real-Time Chat
Clients and craftsmen need to negotiate details before confirming a booking. I used Firebase Realtime Database for chat — it's fast, handles offline sync gracefully, and Flutter has first-class support.
class ChatRepository {
final _db = FirebaseDatabase.instance;
Stream<List<Message>> watchMessages(String bookingId) {
return _db
.ref('chats/$bookingId/messages')
.orderByChild('timestamp')
.onValue
.map((event) {
final data = event.snapshot.value as Map<dynamic, dynamic>?;
if (data == null) return [];
return data.entries
.map((e) => Message.fromMap(Map<String, dynamic>.from(e.value)))
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
});
}
Future<void> sendMessage({
required String bookingId,
required String senderId,
required String text,
}) async {
await _db.ref('chats/$bookingId/messages').push().set({
'senderId': senderId,
'text': text,
'timestamp': ServerValue.timestamp,
'read': false,
});
}
}Moroccan Market Specifics
Building for Morocco introduced challenges I hadn't faced before:
Arabic RTL support — Flutter handles RTL well with Directionality widget and TextDirection.rtl, but it affects every layout decision. I used Localizations with Arabic, French, and Darija (Moroccan Arabic) string tables.
// main.dart
MaterialApp(
localizationsDelegates: [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: [
Locale('ar'),
Locale('fr'),
Locale('ary'), // Darija
],
locale: ref.watch(localeProvider),
)Phone number as identity — most Moroccan users don't use email for apps. Phone number OTP via WhatsApp (not SMS — WhatsApp is ubiquitous) had 3x the completion rate of SMS OTP in early testing.
Cash payment — online payment adoption is lower than Europe. The booking flow had to support "pay in cash on completion" as the primary option, with online payment as secondary.
Variable connectivity — 4G is common in cities but drops frequently. I used connectivity_plus to detect offline state and queue operations:
class OfflineQueueService {
final _queue = <PendingAction>[];
void enqueue(PendingAction action) {
_queue.add(action);
_persist();
}
Future<void> flush() async {
final toProcess = List<PendingAction>.from(_queue);
_queue.clear();
for (final action in toProcess) {
try {
await action.execute();
} catch (e) {
_queue.add(action); // re-queue on failure
}
}
}
}What I'd Do Differently
-
Earlier focus on craftsman onboarding — the supply side is harder than the demand side. Getting craftsmen to register, complete profiles, and actually use the app required more hand-holding than expected.
-
WhatsApp deep integration from day one — in Morocco, if it doesn't work with WhatsApp, it's friction. Booking confirmations via WhatsApp (using the Business API) dramatically improved confirmation rates.
-
Simpler rating system — a 5-star rating with a free-text review was rarely filled. A simple thumbs up/down with pre-set tags ("Punctual", "Quality work", "Fair price") got 4x the response rate.
Building for a specific market forces you to question every UX assumption you've built up from Western app patterns.