Skip to main content

Mobile Development8 min read

Build a Qibla Finder in Flutter

Build a Qibla finder in Flutter: compute the Kaaba bearing from GPS, read the compass sensor, render a smooth CustomPainter dial, and handle edge cases.

  • #flutter
  • #compass
  • #qibla
  • #location
  • #custompainter
  • #mobile-development
Build a Qibla Finder in Flutter

A Qibla finder looks like a simple feature: point the needle at Mecca and be done. When I built one for the Punj Surah app, I learned it is actually two hard problems glued together: computing the bearing to the Kaaba from anywhere on Earth, and reading the device compass in a way that does not jitter like a nervous dashboard light.

This guide shows both, end to end, in Flutter. You will compute the great-circle bearing with plain Dart math, read the magnetometer with the flutter_compass plugin, render a smooth rotating dial with CustomPainter, and handle the permissions and sensor edge cases that separate a demo from something you can ship. The techniques below are the ones that kept the feature stable in production.

Compute the Qibla bearing in Dart

The Qibla is the great-circle direction from your position to the Kaaba in Mecca (21.422487 N, 39.826206 E). A flat-map straight line is wrong over long distances, so use the spherical-law-of-cosines bearing formula. It needs no packages:

import 'dart:math' as math;

/// Returns the Qibla bearing in degrees (0-360, clockwise from true north).
double qiblaBearing(double userLat, double userLng) {
  const kaabaLat = 21.422487;
  const kaabaLng = 39.826206;

  final lat1 = userLat * math.pi / 180;
  final lat2 = kaabaLat * math.pi / 180;
  final deltaLng = (kaabaLng - userLng) * math.pi / 180;

  final y = math.sin(deltaLng);
  final x = math.cos(lat1) * math.tan(lat2) -
      math.sin(lat1) * math.cos(deltaLng);

  return (math.atan2(y, x) * 180 / math.pi + 360) % 360;
}

Three things to get right here:

  1. Convert degrees to radians first. Every trig function in dart:math works in radians. Forgetting the conversion is the single most common bug in compass code.
  2. Normalize to 0-360. atan2 returns -180 to 180; adding 360 and taking modulo 360 keeps the bearing in the range users and compass widgets expect.
  3. This is a pure function — test it. Bearings for known cities are easy to verify: from Jakarta the Qibla is roughly 295 degrees, from New York roughly 58 degrees, from London roughly 119 degrees. Add a small unit test with two or three known values before wiring it into the UI.

The bearing changes with the user's position, so recompute it whenever the location updates. It only depends on GPS coordinates, which means it works fully offline once you have a fix.

Read the compass heading

Dart cannot read the magnetometer directly. The community-standard package is flutter_compass, which exposes a stream of CompassEvents:

import 'package:flutter_compass/flutter_compass.dart';

StreamBuilder<CompassEvent>(
  stream: FlutterCompass.events,
  builder: (context, snapshot) {
    if (snapshot.hasError) {
      return Text('Compass error: ${snapshot.error}');
    }
    if (!snapshot.hasData) {
      return const CircularProgressIndicator();
    }
    final heading = snapshot.data!.heading ?? 0.0;
    return Text('Heading: ${heading.toStringAsFixed(1)} degrees');
  },
)

Two details matter:

  • Always handle the null heading. heading is nullable; on devices without a magnetometer it stays null. A null check here is the difference between a graceful message and a crash report.
  • The plugin reports magnetic north, not true north. The bearing formula gives the direction from true north. In most populated areas the magnetic declination (the angle between the two) is only a few degrees, but for a prayer-direction feature you should say so in your app or correct for it. Many developers quietly ignore this; users near high-declination regions will notice.

Raw sensor output is noisy. If you feed heading straight into a rotating dial, the needle shakes constantly. Apply a simple low-pass filter to smooth it:

double _smoothedHeading = 0.0;

double smoothHeading(double newHeading) {
  const alpha = 0.15; // smaller = smoother but slower
  _smoothedHeading = _smoothedHeading * (1 - alpha) + newHeading * alpha;
  return _smoothedHeading;
}

One caveat: this naive version glitches when the heading wraps from 359 back to 0 degrees. For a Qibla dial, snap-smoothing is usually acceptable, but if you want polish, interpolate along the shorter arc by handling the wraparound explicitly.

Render a smooth Qibla dial

With the bearing and the smoothed heading in hand, the dial is a CustomPainter that draws the needle at qiblaBearing - heading degrees from vertical:

import 'package:flutter/material.dart';

class QiblaDialPainter extends CustomPainter {
  final double heading; // device heading, 0-360
  final double qibla;   // qibla bearing, 0-360

  const QiblaDialPainter({required this.heading, required this.qibla});

  @override
  void paint(Canvas canvas, Size size) {
    final center = size.center(Offset.zero);
    final radius = size.shortestSide / 2 - 16;

    // Static dial ring
    canvas.drawCircle(
      center,
      radius,
      Paint()..color = Colors.white24,
    );

    // North tick at the top
    canvas.drawLine(
      Offset(center.dx, center.dy - radius),
      Offset(center.dx, center.dy - radius + 18),
      Paint()
        ..color = Colors.white70
        ..strokeWidth = 4,
    );

    // Qibla needle, rotated relative to the device heading
    final angle = (qibla - heading) * 3.141592653589793 / 180;
    final tip = Offset(
      center.dx + radius * math.sin(angle),
      center.dy - radius * math.cos(angle),
    );
    canvas.drawLine(
      center,
      tip,
      Paint()
        ..color = Colors.greenAccent
        ..strokeWidth = 6
        ..strokeCap = StrokeCap.round,
    );
  }

  @override
  bool shouldRepaint(QiblaDialPainter oldDelegate) =>
      oldDelegate.heading != heading || oldDelegate.qibla != qibla;
}

Usage is straightforward — drop it inside the compass stream builder:

StreamBuilder<CompassEvent>(
  stream: FlutterCompass.events,
  builder: (context, snapshot) {
    final rawHeading = snapshot.data?.heading ?? 0.0;
    final heading = smoothHeading(rawHeading);
    return CustomPaint(
      size: const Size(280, 280),
      painter: QiblaDialPainter(heading: heading, qibla: qibla),
    );
  },
)

Keep shouldRepaint tight: repainting on every frame is fine here because only the needle moves, but returning true unconditionally on a stream that fires dozens of times per second wastes GPU time on low-end devices.

Permissions: location on Android and iOS

You need the user's position for the bearing. Use geolocator and request permission with a clear rationale before the first Qibla screen appears:

import 'package:geolocator/geolocator.dart';

Future<Position> getUserPosition() async {
  final serviceEnabled = await Geolocator.isLocationServiceEnabled();
  if (!serviceEnabled) {
    throw Exception('Location services are disabled.');
  }
  var permission = await Geolocator.checkPermission();
  if (permission == LocationPermission.denied) {
    permission = await Geolocator.requestPermission();
    if (permission == LocationPermission.denied) {
      throw Exception('Location permission denied.');
    }
  }
  if (permission == LocationPermission.deniedForever) {
    throw Exception('Location permission permanently denied. '
        'Please enable it in app settings.');
  }
  return Geolocator.getCurrentPosition();
}

Manifest and Info.plist additions are required. On Android add the location permissions to AndroidManifest.xml; on iOS add NSLocationWhenInUseUsageDescription to Info.plist. If you skip the iOS usage description, the app crashes on launch when location is requested — it is not optional.

A pattern that works well: cache the last known position and compute the bearing from it immediately, then refine it when the fresh GPS fix arrives. This makes the Qibla screen feel instant instead of showing a spinner while GPS warms up. I covered battery-friendly location handling in more depth in Flutter Geofencing Without Battery Drain.

Edge cases you must handle

These are the failures real users hit, and they are where most tutorial implementations fall apart:

  • No magnetometer. Budget devices sometimes lack one. Detect the null heading and show a clear message — "this device has no compass sensor" — instead of a dead needle.
  • Uncalibrated compass. After travel or near magnets, the heading drifts. Show a small hint asking the user to wave the phone in a figure-8 motion; both Android and iOS recalibrate on this gesture.
  • Location off or denied. Offer manual city selection as a fallback so the bearing still works with an approximate position. A Qibla finder that hard-crashes without GPS is a one-star review.
  • Indoors interference. Steel structures and speakers bend the magnetic field. If the heading jumps wildly, consider displaying a steadiness indicator so users know when to trust the needle.
  • Battery. Both GPS and the magnetometer stream drain power. Pause the compass stream and stop location updates when the Qibla screen is not visible.

Handling the Android permission and settings flows cleanly is a recurring theme in device-feature work; I wrote about the notification-policy permission dance in Control Android Ringer Mode in Flutter if you want a deeper look at how Android guards system access.

Shipping it: what production taught me

The Qibla finder shipped as part of Punj Surah, an offline-first companion app for daily recitation with high-quality MP3 audio, page-by-page reading, a Salah tracker, and a Tasbeeh counter. The app holds a 99.9% crash-free rate with 10,000+ downloads, and the compass work contributed two lessons I apply to every sensor feature since:

  1. Smooth the sensor, not just the UI. A dial that shakes at 20 Hz looks broken even when the math is right. The low-pass filter was the single biggest perceived-quality improvement.
  2. Fail loudly but gracefully. Every sensor code path — null heading, denied location, disabled services — ends in a human-readable message with a next step. Users forgive a limitation they understand; they do not forgive a spinner that never resolves.

A Qibla finder is a great first sensor feature: the math is a pure function you can unit test, the UI is one CustomPainter, and the whole thing works offline once you have a position fix. Build it well, and the pattern — bearing plus heading plus smooth rendering — transfers to any navigation-style UI: prayer times widgets, running direction trackers, AR pointers, and more.

Building something that needs sensor access or native bridging? Get in touch and let's talk about your project.

Frequently asked questions

Can I compute the Qibla direction offline in Flutter?
Yes. The great-circle bearing from your GPS coordinates to the Kaaba is pure Dart math with no network calls, so it works fully offline once you have a position fix.
Why does my Flutter compass needle jitter constantly?
Raw magnetometer output is noisy. Apply a low-pass filter to smooth the heading before rendering it, and handle the 359-to-0 degree wraparound explicitly.
What happens on devices without a compass sensor?
The heading from flutter_compass is nullable and stays null on devices without a magnetometer. Detect this and show a clear message rather than rendering a dead needle.

Written by Muhammad Uzair

Flutter Developer & Mobile Engineering Team Lead.

Work with me
All articles
  • Mobile Development

    7 min read

    Control Android Ringer Mode in Flutter

    Control Android ringer mode from Flutter with a MethodChannel and AudioManager: silent, vibrate, and normal profiles plus the Do Not Disturb access step.

  • Mobile Development

    9 min read

    Flutter Exact Alarm Scheduling on Android

    Build reliable alarms in Flutter on modern Android: exact alarm permissions, Doze-safe scheduling, reboot recovery, and fallbacks from a real alarm app.

  • Mobile Development

    8 min read

    Building an Ethiopian Calendar in Flutter

    Build an Ethiopian calendar in Flutter with bidirectional date conversion, Orthodox fasting data, and a fully offline design based on a real published app.