import 'dart:async'; import 'dart:io' show Platform; import 'dart:math' as math; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_qiblah/flutter_qiblah.dart'; import 'package:geolocator/geolocator.dart'; import '../../../app/icons/app_icons.dart'; import '../../../app/theme/app_colors.dart'; import '../../../data/services/unsplash_service.dart'; class QiblaScreen extends ConsumerStatefulWidget { const QiblaScreen({super.key}); @override ConsumerState createState() => _QiblaScreenState(); } class _QiblaScreenState extends ConsumerState { static const _geomagneticChannel = MethodChannel('com.jamshalat.app/geomagnetic'); static const double _alignmentThreshold = 3.0; // Fallback simulated data for environments without compass hardware (like macOS emulator) double _qiblaAngle = 295.0; // Default Jakarta to Mecca Map? _unsplashPhoto; late Future<_QiblaBootstrapState> _bootstrapFuture; StreamSubscription? _positionSubscription; bool _isLiveTrackingActive = false; bool _isDeclinationSyncInProgress = false; double? _magneticDeclination; DateTime? _lastDeclinationSyncAt; Position? _lastDeclinationPosition; double? _lastHeading; double _smoothedHeadingDelta = 0; int _headingSamples = 0; Future<_QiblaBootstrapState> _checkDeviceSupport() async { final isMobile = Platform.isAndroid || Platform.isIOS; if (!isMobile) { return const _QiblaBootstrapState(_QiblaMode.simulated); } final hasSensorSupport = await FlutterQiblah.androidDeviceSensorSupport(); if (hasSensorSupport != true) { return const _QiblaBootstrapState(_QiblaMode.simulated); } final locationStatus = await FlutterQiblah.checkLocationStatus(); if (!locationStatus.enabled) { return const _QiblaBootstrapState(_QiblaMode.locationDisabled); } var permission = locationStatus.status; if (permission == LocationPermission.denied) { permission = await FlutterQiblah.requestPermissions(); } if (permission == LocationPermission.denied) { return const _QiblaBootstrapState(_QiblaMode.permissionDenied); } if (permission == LocationPermission.deniedForever) { return const _QiblaBootstrapState(_QiblaMode.permissionDeniedForever); } return const _QiblaBootstrapState(_QiblaMode.live); } @override void initState() { super.initState(); _bootstrapFuture = _checkDeviceSupport(); // Pre-calculate static fallback _calculateStaticQibla(); _loadUnsplashPhoto(); } Future _loadUnsplashPhoto() async { final photo = await UnsplashService.instance.getIslamicPhoto(); if (!mounted || photo == null) return; setState(() => _unsplashPhoto = photo); } @override void dispose() { _stopLiveTracking(); FlutterQiblah().dispose(); super.dispose(); } void _retryBootstrap() { _stopLiveTracking(); _isLiveTrackingActive = false; _magneticDeclination = null; _lastDeclinationSyncAt = null; _lastDeclinationPosition = null; _isDeclinationSyncInProgress = false; _lastHeading = null; _smoothedHeadingDelta = 0; _headingSamples = 0; setState(() { _bootstrapFuture = _checkDeviceSupport(); }); } void _calculateStaticQibla() { // Default to Jakarta coordinates _qiblaAngle = _calculateQiblaBearing( latitude: -6.2088, longitude: 106.8456, ); } double _normalizeAngle(double angle) => (angle % 360 + 360) % 360; double _alignmentDelta(double bearing) { return _shortestAngleDelta(_normalizeAngle(bearing), 0); } double _calculateQiblaBearing({ required double latitude, required double longitude, }) { const meccaLat = 21.422487; const meccaLon = 39.826206; final lat1 = latitude * (math.pi / 180); const lat2 = meccaLat * (math.pi / 180); final dLon = (meccaLon - longitude) * (math.pi / 180); final y = math.sin(dLon) * math.cos(lat2); final x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dLon); return _normalizeAngle(math.atan2(y, x) * (180 / math.pi)); } void _ensureLiveTracking() { if (_isLiveTrackingActive) return; _isLiveTrackingActive = true; unawaited(_startLiveTracking()); } void _stopLiveTracking() { _positionSubscription?.cancel(); _positionSubscription = null; } Future _startLiveTracking() async { try { final initialPosition = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.best, ), ); await _processPositionUpdate(initialPosition); } catch (_) { // Keep existing fallback bearing when location isn't ready. } const settings = LocationSettings( accuracy: LocationAccuracy.bestForNavigation, distanceFilter: 10, ); _positionSubscription = Geolocator.getPositionStream( locationSettings: settings, ).listen( (position) => unawaited(_processPositionUpdate(position)), onError: (_) { // Keep latest known values when location stream errors. }, ); } bool _shouldRefreshDeclination(Position position) { if (!Platform.isAndroid) return false; if (_lastDeclinationSyncAt == null || _lastDeclinationPosition == null) { return true; } final movedMeters = Geolocator.distanceBetween( _lastDeclinationPosition!.latitude, _lastDeclinationPosition!.longitude, position.latitude, position.longitude, ); final minutesSinceLastSync = DateTime.now().difference(_lastDeclinationSyncAt!).inMinutes; return movedMeters >= 500 || minutesSinceLastSync >= 60; } Future _processPositionUpdate(Position position) async { final nextBearing = _calculateQiblaBearing( latitude: position.latitude, longitude: position.longitude, ); var shouldRebuild = false; if ((nextBearing - _qiblaAngle).abs() > 0.02) { _qiblaAngle = nextBearing; shouldRebuild = true; } if (_shouldRefreshDeclination(position) && !_isDeclinationSyncInProgress && mounted) { _isDeclinationSyncInProgress = true; try { final declination = await _geomagneticChannel.invokeMethod( 'getDeclination', { 'latitude': position.latitude, 'longitude': position.longitude, 'altitude': position.altitude, 'timestamp': DateTime.now().millisecondsSinceEpoch, }, ); if (declination != null) { _magneticDeclination = declination; _lastDeclinationSyncAt = DateTime.now(); _lastDeclinationPosition = position; shouldRebuild = true; } } catch (_) { // Fall back to raw magnetic heading if declination API isn't available. } finally { _isDeclinationSyncInProgress = false; } } if (shouldRebuild && mounted) { setState(() {}); } } String _directionForAngle(double angle) { final normalized = _normalizeAngle(angle); if (normalized >= 337.5 || normalized < 22.5) { return 'N'; } else if (normalized < 67.5) { return 'NE'; } else if (normalized < 112.5) { return 'E'; } else if (normalized < 157.5) { return 'SE'; } else if (normalized < 202.5) { return 'S'; } else if (normalized < 247.5) { return 'SW'; } else if (normalized < 292.5) { return 'W'; } else { return 'NW'; } } double _shortestAngleDelta(double a, double b) { final raw = (a - b).abs(); return raw > 180 ? 360 - raw : raw; } _CalibrationQuality _assessCalibration(double heading) { if (_lastHeading == null) { _lastHeading = heading; _headingSamples = 1; return _CalibrationQuality.initializing; } final delta = _shortestAngleDelta(heading, _lastHeading!); _smoothedHeadingDelta = _headingSamples == 1 ? delta : (_smoothedHeadingDelta * 0.8) + (delta * 0.2); _lastHeading = heading; _headingSamples += 1; if (_headingSamples < 8) return _CalibrationQuality.initializing; if (_smoothedHeadingDelta <= 1.2) return _CalibrationQuality.good; if (_smoothedHeadingDelta <= 3.0) return _CalibrationQuality.medium; return _CalibrationQuality.poor; } String _calibrationLabel(_CalibrationQuality quality) { switch (quality) { case _CalibrationQuality.initializing: return 'KALIBRASI: MEMBACA SENSOR'; case _CalibrationQuality.good: return 'KALIBRASI: STABIL'; case _CalibrationQuality.medium: return 'KALIBRASI: CUKUP'; case _CalibrationQuality.poor: return 'KALIBRASI: PERLU PENYESUAIAN'; } } String _calibrationHint(_CalibrationQuality quality) { switch (quality) { case _CalibrationQuality.initializing: return 'Tahan perangkat beberapa detik untuk akurasi awal.'; case _CalibrationQuality.good: return 'Arah kompas stabil. Siap digunakan.'; case _CalibrationQuality.medium: return 'Arah cukup stabil, jauhkan dari benda logam.'; case _CalibrationQuality.poor: return 'Gerakkan perangkat membentuk angka 8 agar kompas lebih akurat.'; } } Color _calibrationColor(_CalibrationQuality quality) { switch (quality) { case _CalibrationQuality.initializing: return Colors.amber; case _CalibrationQuality.good: return Colors.green; case _CalibrationQuality.medium: return Colors.orange; case _CalibrationQuality.poor: return Colors.redAccent; } } @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return FutureBuilder( future: _bootstrapFuture, builder: (_, AsyncSnapshot<_QiblaBootstrapState> snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Scaffold( body: SafeArea( top: false, bottom: true, child: Center(child: CircularProgressIndicator()), ), ); } if (snapshot.hasError) { return _buildErrorLayout( context, isDark, title: 'Qibla belum siap', subtitle: 'Terjadi kendala saat menginisialisasi sensor.', actionLabel: 'Coba Lagi', onAction: _retryBootstrap, ); } final bootstrap = snapshot.data; if (bootstrap == null) { return _buildErrorLayout( context, isDark, title: 'Qibla belum siap', subtitle: 'Status sensor belum tersedia.', actionLabel: 'Coba Lagi', onAction: _retryBootstrap, ); } if (bootstrap.mode == _QiblaMode.live) { return _buildLiveQibla(context, isDark); } if (bootstrap.mode == _QiblaMode.simulated) { // Device lacks compass sensor (desktop/emulators) return _buildSimulatedQibla(context, isDark); } if (bootstrap.mode == _QiblaMode.locationDisabled) { return _buildErrorLayout( context, isDark, title: 'Aktifkan lokasi', subtitle: 'Compass membutuhkan layanan lokasi agar arah kiblat bisa dihitung.', actionLabel: 'Buka Pengaturan Lokasi', onAction: () async { await Geolocator.openLocationSettings(); _retryBootstrap(); }, ); } if (bootstrap.mode == _QiblaMode.permissionDeniedForever) { return _buildErrorLayout( context, isDark, title: 'Izin lokasi diblokir', subtitle: 'Aktifkan izin lokasi dari pengaturan aplikasi untuk memakai Compass.', actionLabel: 'Buka Pengaturan Aplikasi', onAction: () async { await Geolocator.openAppSettings(); _retryBootstrap(); }, ); } if (bootstrap.mode == _QiblaMode.permissionDenied) { return _buildErrorLayout( context, isDark, title: 'Izin lokasi diperlukan', subtitle: 'Izinkan lokasi agar Compass bisa menentukan arah kiblat.', actionLabel: 'Izinkan Lokasi', onAction: _retryBootstrap, ); } return _buildSimulatedQibla(context, isDark); }, ); } Widget _buildLiveQibla(BuildContext context, bool isDark) { _ensureLiveTracking(); return StreamBuilder( stream: FlutterQiblah.qiblahStream, builder: (_, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Scaffold( body: SafeArea( top: false, bottom: true, child: Center(child: CircularProgressIndicator()), ), ); } if (snapshot.hasError) { return _buildErrorLayout( context, isDark, title: 'Sensor kompas belum stabil', subtitle: 'Gerakkan perangkat membentuk angka 8 lalu coba lagi.', actionLabel: 'Muat Ulang', onAction: _retryBootstrap, ); } final qiblahDirection = snapshot.data; if (qiblahDirection == null) { return _buildErrorLayout( context, isDark, title: 'Menunggu sensor arah', subtitle: 'Pastikan perangkat memiliki akses sensor dan lokasi.', actionLabel: 'Coba Lagi', onAction: _retryBootstrap, ); } final displayAngle = _normalizeAngle(_qiblaAngle); final rawHeading = _normalizeAngle(qiblahDirection.direction); final trueHeading = Platform.isAndroid ? _normalizeAngle(rawHeading + (_magneticDeclination ?? 0)) : rawHeading; final needleBearing = _normalizeAngle(trueHeading + (360 - displayAngle)); final needleAngle = -(needleBearing * (math.pi / 180)); final qiblaDelta = _alignmentDelta(needleBearing); final isAligned = qiblaDelta <= _alignmentThreshold; final calibrationQuality = _assessCalibration(rawHeading); return _buildQiblaLayout( context: context, isDark: isDark, angleRad: needleAngle, displayAngle: displayAngle, directionLabel: _directionForAngle(displayAngle), isLive: true, isAligned: isAligned, qiblaDelta: qiblaDelta, calibrationQuality: calibrationQuality, ); }, ); } Widget _buildSimulatedQibla(BuildContext context, bool isDark) { return _buildQiblaLayout( context: context, isDark: isDark, angleRad: _qiblaAngle * (math.pi / 180), displayAngle: _normalizeAngle(_qiblaAngle), directionLabel: _directionForAngle(_qiblaAngle), isLive: false, isAligned: false, qiblaDelta: null, calibrationQuality: null, ); } Widget _buildErrorLayout( BuildContext context, bool isDark, { required String title, required String subtitle, required String actionLabel, required VoidCallback onAction, }) { return Scaffold( appBar: AppBar( title: const Text('Arah Kiblat'), leading: IconButton( icon: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( shape: BoxShape.circle, color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight, border: Border.all(color: AppColors.cream), ), child: const AppIcon(glyph: AppIcons.backArrow, size: 18), ), onPressed: () => Navigator.pop(context), ), ), body: SafeArea( top: false, bottom: true, child: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ AppIcon( glyph: AppIcons.locationOffline, size: 34, color: isDark ? Colors.white70 : AppColors.textPrimaryLight, ), const SizedBox(height: 16), Text( title, textAlign: TextAlign.center, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w800, ), ), const SizedBox(height: 10), Text( subtitle, textAlign: TextAlign.center, style: TextStyle( fontSize: 14, color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ), const SizedBox(height: 20), FilledButton( onPressed: onAction, child: Text(actionLabel), ), ], ), ), ), ), ); } Widget _buildQiblaLayout({ required BuildContext context, required bool isDark, required double angleRad, required double displayAngle, required String directionLabel, required bool isLive, required bool isAligned, required double? qiblaDelta, required _CalibrationQuality? calibrationQuality, }) { return Scaffold( appBar: AppBar( title: const Text('Arah Kiblat'), leading: IconButton( icon: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( shape: BoxShape.circle, color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight, border: Border.all(color: AppColors.cream), ), child: const AppIcon(glyph: AppIcons.backArrow, size: 18), ), onPressed: () => Navigator.pop(context), ), actions: [ IconButton( icon: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( shape: BoxShape.circle, color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight, border: Border.all(color: AppColors.cream), ), child: AppIcon( glyph: isLive ? AppIcons.locationActive : AppIcons.locationOffline, size: 18, ), ), onPressed: () { if (isLive) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Menggunakan sensor perangkat aktual'), ), ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Mode Simulasi: Hardware kompas tidak terdeteksi'), ), ); } }, ), ], ), body: SafeArea( top: false, bottom: true, child: Container( width: double.infinity, clipBehavior: Clip.hardEdge, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: isDark ? [AppColors.backgroundDark, AppColors.surfaceDark] : [ AppColors.backgroundLight, AppColors.primary.withValues(alpha: 0.05), ], ), ), child: Stack( fit: StackFit.expand, children: [ if ((_unsplashPhoto?['imageUrl'] ?? '').isNotEmpty) Opacity( opacity: isDark ? 0.2 : 0.12, child: CachedNetworkImage( imageUrl: _unsplashPhoto!['imageUrl']!, fit: BoxFit.cover, ), ), DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: isDark ? [ AppColors.backgroundDark.withValues(alpha: 0.9), AppColors.surfaceDark.withValues(alpha: 0.88), ] : [ AppColors.backgroundLight.withValues(alpha: 0.88), AppColors.primary.withValues(alpha: 0.08), ], ), ), ), Column( children: [ const SizedBox(height: 32), // Location label Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const AppIcon( glyph: AppIcons.location, size: 16, color: AppColors.primary), const SizedBox(width: 4), Text( 'Ka\'bah, Makkah', style: TextStyle( fontSize: 14, color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ), ], ), const SizedBox(height: 8), // Degree + direction Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '${displayAngle.round()}°', style: const TextStyle( fontSize: 48, fontWeight: FontWeight.w800, ), ), const SizedBox(width: 12), Text( directionLabel, style: const TextStyle( fontSize: 48, fontWeight: FontWeight.w800, color: AppColors.primary, ), ), ], ), if (isLive && qiblaDelta != null) ...[ const SizedBox(height: 14), AnimatedContainer( duration: const Duration(milliseconds: 360), curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(999), gradient: LinearGradient( colors: isAligned ? [ AppColors.primary.withValues(alpha: 0.24), AppColors.primary.withValues(alpha: 0.14), ] : [ Colors.white .withValues(alpha: isDark ? 0.1 : 0.65), Colors.white .withValues(alpha: isDark ? 0.06 : 0.4), ], ), border: Border.all( color: isAligned ? AppColors.primary.withValues(alpha: 0.65) : AppColors.cream.withValues(alpha: 0.45), ), boxShadow: [ if (isAligned) BoxShadow( color: AppColors.primary.withValues(alpha: 0.28), blurRadius: 18, spreadRadius: 0.5, ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ AppIcon( glyph: isAligned ? AppIcons.checkCircle : AppIcons.qibla, size: 18, color: isAligned ? AppColors.primary : (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight), ), const SizedBox(width: 10), Text( isAligned ? 'Sudah sejajar kiblat' : 'Putar ${qiblaDelta.ceil()}° lagi', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: isAligned ? AppColors.primary : (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight), ), ), ], ), ), ], const SizedBox(height: 24), // Compass Expanded( child: Center( child: SizedBox( width: 300, height: 300, child: CustomPaint( painter: _CompassPainter( qiblaAngle: angleRad, isDark: isDark, isAligned: isAligned, ), ), ), ), ), const SizedBox(height: 16), // Calibration status Container( padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 10), decoration: BoxDecoration( color: isLive ? AppColors.primary.withValues(alpha: 0.1) : Colors.orange.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(50), ), child: Text( isLive ? 'SENSOR AKTIF' : 'MODE SIMULASI (TIDAK ADA SENSOR)', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 1.5, color: isLive ? AppColors.primary : Colors.orange, ), ), ), if (isLive && calibrationQuality != null) ...[ const SizedBox(height: 10), Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8), decoration: BoxDecoration( color: _calibrationColor(calibrationQuality) .withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 8, height: 8, decoration: BoxDecoration( shape: BoxShape.circle, color: _calibrationColor(calibrationQuality), ), ), const SizedBox(width: 8), Text( _calibrationLabel(calibrationQuality), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 1.2, color: _calibrationColor(calibrationQuality), ), ), ], ), ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( _calibrationHint(calibrationQuality), textAlign: TextAlign.center, style: TextStyle( fontSize: 12, color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ), ), ], const SizedBox(height: 48), ], ), ], ), ), ), ); } } class _CompassPainter extends CustomPainter { final double qiblaAngle; final bool isDark; final bool isAligned; _CompassPainter({ required this.qiblaAngle, required this.isDark, required this.isAligned, }); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = size.width / 2 - 8; // Outer circle final outerPaint = Paint() ..color = AppColors.primary.withValues(alpha: 0.15) ..style = PaintingStyle.stroke ..strokeWidth = 2; canvas.drawCircle(center, radius, outerPaint); // Inner dashed circle final innerPaint = Paint() ..color = AppColors.primary.withValues(alpha: 0.08) ..style = PaintingStyle.stroke ..strokeWidth = 1; canvas.drawCircle(center, radius * 0.7, innerPaint); // Cross lines final crossPaint = Paint() ..color = AppColors.primary.withValues(alpha: 0.1) ..strokeWidth = 1; canvas.drawLine(Offset(center.dx, center.dy - radius), Offset(center.dx, center.dy + radius), crossPaint); canvas.drawLine(Offset(center.dx - radius, center.dy), Offset(center.dx + radius, center.dy), crossPaint); // Diagonals final diagOffset = radius * 0.707; canvas.drawLine(Offset(center.dx - diagOffset, center.dy - diagOffset), Offset(center.dx + diagOffset, center.dy + diagOffset), crossPaint); canvas.drawLine(Offset(center.dx + diagOffset, center.dy - diagOffset), Offset(center.dx - diagOffset, center.dy + diagOffset), crossPaint); // Center dot final centerDotPaint = Paint() ..color = AppColors.primary.withValues(alpha: 0.3) ..style = PaintingStyle.stroke ..strokeWidth = 2; canvas.drawCircle(center, 6, centerDotPaint); // Qibla direction line final qiblaEndX = center.dx + radius * 0.85 * math.cos(qiblaAngle - math.pi / 2); final qiblaEndY = center.dy + radius * 0.85 * math.sin(qiblaAngle - math.pi / 2); // Glow effect final glowPaint = Paint() ..color = AppColors.primary.withValues(alpha: 0.3) ..strokeWidth = 6 ..strokeCap = StrokeCap.round; canvas.drawLine(center, Offset(qiblaEndX, qiblaEndY), glowPaint); // Main line final linePaint = Paint() ..color = AppColors.primary ..strokeWidth = 3 ..strokeCap = StrokeCap.round; canvas.drawLine(center, Offset(qiblaEndX, qiblaEndY), linePaint); final iconCenter = Offset(qiblaEndX, qiblaEndY); // Qibla icon circle at end final iconPaint = Paint() ..color = isAligned ? AppColors.primary : AppColors.primary.withValues(alpha: 0.9); canvas.drawCircle(iconCenter, 17, iconPaint); final iconBorderPaint = Paint() ..color = AppColors.cream.withValues(alpha: 0.82) ..style = PaintingStyle.stroke ..strokeWidth = 1.4; canvas.drawCircle(iconCenter, 17, iconBorderPaint); // Kaaba marker final kaabaBodyPaint = Paint() ..color = isDark ? const Color(0xFF14191D) : const Color(0xFF11161B) ..style = PaintingStyle.fill; final kaabaBody = RRect.fromRectAndRadius( Rect.fromCenter( center: iconCenter, width: 12, height: 11, ), const Radius.circular(1.8), ); canvas.drawRRect(kaabaBody, kaabaBodyPaint); final kaabaGoldPaint = Paint() ..color = const Color(0xFFF0C14A) ..style = PaintingStyle.fill; canvas.drawRect( Rect.fromCenter( center: Offset(iconCenter.dx, iconCenter.dy - 2.4), width: 12, height: 2.0, ), kaabaGoldPaint, ); final kaabaDoorPaint = Paint() ..color = const Color(0xFF7B5A16) ..style = PaintingStyle.fill; canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromCenter( center: Offset(iconCenter.dx, iconCenter.dy + 1.8), width: 2.8, height: 4.2, ), const Radius.circular(0.6), ), kaabaDoorPaint, ); } @override bool shouldRepaint(covariant _CompassPainter oldDelegate) => qiblaAngle != oldDelegate.qiblaAngle || isAligned != oldDelegate.isAligned; } enum _QiblaMode { live, simulated, locationDisabled, permissionDenied, permissionDeniedForever, } enum _CalibrationQuality { initializing, good, medium, poor, } class _QiblaBootstrapState { final _QiblaMode mode; const _QiblaBootstrapState(this.mode); }