1047 lines
34 KiB
Dart
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.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<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);
|
|
}
|