Files
2026-03-18 00:07:10 +07:00

1047 lines
34 KiB
Dart

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<QiblaScreen> createState() => _QiblaScreenState();
}
class _QiblaScreenState extends ConsumerState<QiblaScreen> {
static const _geomagneticChannel =
MethodChannel('com.jamshalat.diary/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<String, String>? _unsplashPhoto;
late Future<_QiblaBootstrapState> _bootstrapFuture;
StreamSubscription<Position>? _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<void> _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<void> _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<void> _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<double>(
'getDeclination',
<String, dynamic>{
'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<QiblahDirection> 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);
}