recite_quran 1.0.3 copy "recite_quran: ^1.0.3" to clipboard
recite_quran: ^1.0.3 copied to clipboard

Real-time on-device Quran recitation tracking, dynamic DTW word alignment, and deterministic Tajweed rule verification engine for Flutter.

example/lib/main.dart

/// بسم الله الرحمن الرحيم
///
/// ReciteQuran — Real-time Quran recitation tracking app.
///
/// Architecture:
///   main.dart → _Orchestrator (manages engine + audio + controller)
///            → TrackingScreen (UI)
///
/// The _Orchestrator initializes:
///   1. Microphone permissions
///   2. Sherpa-ONNX ASR engine (in a background Isolate)
///   3. Quran metadata (quran.json)
///   4. HighlightingController (bridges ASR → UI)
///
/// Recording flow:
///   AudioProcessor captures mic → feeds chunks to Controller →
///   Controller sends to SherpaEngine (Isolate) → gets transcription →
///   matches words against expected Quran text → updates highlighting
library;

import 'dart:async';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:in_app_update/in_app_update.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';

import 'package:recite_quran/recite_quran.dart';
import 'state/app_state.dart';
import 'ui/tracking_screen.dart';
import 'ui/widgets/dialogs/theme_selection_dialog.dart';
import 'ui/widgets/dialogs/permission_dialog.dart';
import 'package:shared_preferences/shared_preferences.dart';

// //logs
List<String> globalSessionLogs = [];

void main() async {
  runZonedGuarded(
    () async {
      WidgetsFlutterBinding.ensureInitialized();

      // Initialize iOS audio session to disable VPIO processing for accurate ASR
      if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
        try {
          final session = await AudioSession.instance;
          await session.configure(
            AudioSessionConfiguration(
              avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
              avAudioSessionCategoryOptions:
                  AVAudioSessionCategoryOptions.defaultToSpeaker |
                  AVAudioSessionCategoryOptions.allowBluetooth,
              avAudioSessionMode: AVAudioSessionMode.defaultMode,
            ),
          );
        } catch (e) {
          DebugLogger.logSimple(
            'AudioSession',
            'Failed to configure AudioSession: $e',
          );
        }
      }

      if (kReleaseMode) {
        debugPrint = (String? message, {int? wrapWidth}) {
          // //logs: Send debugPrint into our custom print zone instead of the void!
          if (message != null) print(message);
        };
      }

      if (!kIsWeb) {
        // Transparent system bars for immersive experience
        SystemChrome.setSystemUIOverlayStyle(
          const SystemUiOverlayStyle(
            statusBarColor: Colors.transparent,
            systemNavigationBarColor: Colors.transparent,
          ),
        );
        SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
      }

      await AppState.instance.load();
      runApp(const QuranApp());
    },
    (error, stack) {
      DebugLogger.logSimple('Error', 'Uncaught Error: $error');
    },
    zoneSpecification: ZoneSpecification(
      print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
        // //logs
        globalSessionLogs.add('[${DateTime.now().toIso8601String()}] $line');
        if (globalSessionLogs.length > 5000) {
          globalSessionLogs.removeRange(0, 1000);
        }

        if (!kReleaseMode || kIsWeb) {
          parent.print(zone, line);
        }
      },
    ),
  );
}

/// Root widget — rebuilds MaterialApp when theme/language changes.
class QuranApp extends StatelessWidget {
  const QuranApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: AppState.instance,
      builder: (context, _) {
        final ThemeColors c = AppState.instance.colors;
        final isDark = AppState.instance.isDarkMode;
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          title: 'Recite Quran - اتلو القران',
          theme: ThemeData(
            brightness: isDark ? Brightness.dark : Brightness.light,
            scaffoldBackgroundColor: c.bg,
            colorScheme: isDark
                ? ColorScheme.dark(primary: c.gold, surface: c.surface)
                : ColorScheme.light(primary: c.gold, surface: c.surface),
          ),
          builder: (context, child) {
            if (!kIsWeb) return child!;
            return Scaffold(
              backgroundColor: isDark ? Colors.black : const Color(0xFFE5E7EB),
              body: Center(
                child: ConstrainedBox(
                  constraints: const BoxConstraints(maxWidth: 480),
                  child: Container(
                    clipBehavior: Clip.antiAlias,
                    decoration: BoxDecoration(
                      color: c.bg,
                      boxShadow: [
                        BoxShadow(
                          color: Colors.black.withValues(alpha: 0.15),
                          blurRadius: 25,
                          spreadRadius: 5,
                        ),
                      ],
                    ),
                    child: child,
                  ),
                ),
              ),
            );
          },
          home: const _Orchestrator(),
        );
      },
    );
  }
}

/// The conductor — owns the ASR engine, audio processor, and controller.
/// Manages the full lifecycle of a recording session.
class _Orchestrator extends StatefulWidget {
  const _Orchestrator();
  @override
  State<_Orchestrator> createState() => _OrchestratorState();
}

class _OrchestratorState extends State<_Orchestrator> {
  final SherpaEngine _engine = SherpaEngine();
  final AudioProcessor _audio = AudioProcessor();
  late final VoiceSearchController _voiceSearchCtrl;

  QuranRepository? _repo;
  HighlightingController? _ctrl;

  bool _isInit = true;
  bool _isRecording = false;
  String _initStatus = 'Starting…';
  bool _isToggling = false; // Prevents double-tap hardware crashes
  bool _isEngineLoading =
      false; // Tracks if ASR is compiling/extracting when user presses record
  bool _isVoiceSearching = false;
  String _voiceSearchAsrText = '';

  // Timer logic removed, we now rely on Sherpa's VAD endpoint with 4s silence

  @override
  void initState() {
    super.initState();
    _voiceSearchCtrl = VoiceSearchController(engine: _engine);

    // Global subscription for Voice Search text and Endpoint auto-stopping
    _engine.transcriptionStream.listen((res) {
      if (_isVoiceSearching && mounted) {
        setState(() {
          _voiceSearchAsrText = res.text;
        });

        // REAL-TIME SEARCH EVALUATION
        _voiceSearchCtrl.processRealtime(res.text).then((rtResult) {
          if (rtResult != null) {
            // Unique match found! Bypass VAD and jump immediately.
            _stopVoiceSearch(precalculatedResult: rtResult);
          } else if (res.isFinal) {
            // Sherpa's VAD detected 4s of silence
            DebugLogger.log(
              'VoiceSearch',
              'Auto-stopping on VAD endpoint (4s silence)',
            );
            _stopVoiceSearch();
          }
        });
      }
    });

    _init();
    // Defer update check by 5s so Play Core native code doesn't compete
    // with XNNPACK/ONNX initialization for memory and IPC resources
    // during the critical startup window.
    Future.delayed(const Duration(seconds: 5), _checkForUpdates);

    if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
      WidgetsBinding.instance.addPostFrameCallback((_) async {
        try {
          await FlutterDisplayMode.setHighRefreshRate();
        } catch (e) {
          DebugLogger.logSimple(
            'DisplayMode',
            'Failed to set high refresh rate: $e',
          );
        }
      });
    }
  }

  Future<void> _checkForUpdates() async {
    if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
    try {
      const platform = MethodChannel('com.recitequran.app/playstore');
      final bool hasPlayStore = await platform.invokeMethod('isPlayStoreInstalled');
      
      if (!hasPlayStore) {
        DebugLogger.logSimple('Update', 'Play Store missing (LineageOS). Bypassing Play Core API.');
        return;
      }

      final info = await InAppUpdate.checkForUpdate();
      if (info.updateAvailability == UpdateAvailability.updateAvailable) {
        await InAppUpdate.performImmediateUpdate();
      }
    } catch (e) {
      DebugLogger.logSimple('Update', "Play Core update check aborted safely: $e");
    }
  }

  @override
  void dispose() {
    _ctrl?.dispose();
    super.dispose();
  }

  /// Sequential initialization pipeline in the background.
  /// Does not block the splash screen for the heavy model extraction.
  Future<void> _init() async {
    try {
      if (mounted) setState(() => _initStatus = 'Preparing ASR engine…');
      _engine.initialize(); // Fire-and-forget in background Isolate

      if (mounted) setState(() => _initStatus = 'Loading Quran database…');
      final service = QuranMetadataService();
      _repo = QuranRepository(service);
      await _repo!.loadSurahAsync(1);

      _voiceSearchCtrl.preloadIndex(); // Fire-and-forget in background

      _ctrl = HighlightingController(
        engine: _engine,
        repository: _repo!,
        isTajweed: AppState.instance.currentMode == AppMode.tajweed,
        // onAyahChanged is called on explicit user actions (manual tap, session start).
        // Automatic ayah-advance uses flushAndResetForNextAyah() inside
        // HighlightingController itself — that handles the flush-before-reset pattern.
        // We only clear the audio processor's frame buffer here (which is a no-op anyway).
        onAyahChanged: () {
          _audio.clearBuffer();
          // DO NOT call _engine.resetBuffer() here.
          // The correct reset path at ayah boundaries is flushAndResetForNextAyah()
          // which is triggered from _onIsolateWordMatched in HighlightingController.
          // Calling resetBuffer() here would wipe the live cache before the flush,
          // silently discarding the final word's right-context tail every ayah.
        },
      );
      if (!kIsWeb) {
        try {
          WakelockPlus.enable();
        } catch (_) {}
      }

      if (mounted) setState(() => _isInit = false);

      WidgetsBinding.instance.addPostFrameCallback((_) async {
        final prefs = await SharedPreferences.getInstance();
        final hasChosenTheme = prefs.getBool('has_chosen_theme') ?? false;
        if (!hasChosenTheme && mounted) {
          showDialog(
            context: context,
            barrierDismissible: false,
            builder: (ctx) => ThemeSelectionDialog(
              onThemeSelected: () {
                prefs.setBool('has_chosen_theme', true);
                Navigator.of(ctx).pop();
              },
            ),
          );
        }
      });
    } catch (e) {
      DebugLogger.logSimple('INIT', '❌ Error: $e');
      if (mounted) setState(() => _initStatus = 'Error: $e');
    }
  }

  /// Toggles recording on/off with hardware-safe locking.
  Future<void> _toggleRecord() async {
    if (_isToggling) return;
    _isToggling = true;

    try {
      if (_isRecording) {
        await _audio.stop();
        _engine.resetBuffer();
        _ctrl?.finalize();
        if (!kIsWeb) {
          try { await WakelockPlus.disable(); } catch (_) {}
        }
        if (mounted) {
          setState(() {
            _isRecording = false;
          });
        }
      } else {
        if (!kIsWeb) {
          final status = await Permission.microphone.request();
          if (status != PermissionStatus.granted) {
            if (mounted) {
              showDialog(
                context: context,
                builder: (ctx) =>
                    const PermissionDialog(reason: PermissionReason.tracking),
              );
            }
            _isToggling = false;
            return;
          }
        }

        // Ensure engine is ready (may still be initializing in background)
        if (!_engine.isInitialized) {
          if (mounted) setState(() => _isEngineLoading = true);
          try {
            await _engine.initialize();
          } finally {
            if (mounted) setState(() => _isEngineLoading = false);
          }
        }

        if (!kIsWeb) {
          try { await WakelockPlus.enable(); } catch (_) {}
        }
        _engine.resetBuffer();

        // Instant UI feedback before hardware mic starts
        if (mounted) setState(() => _isRecording = true);

        if (_ctrl != null) {
          _ctrl!.startRecordingSession();
          _audio
              .start(
                onChunk: (chunk, isFinal) =>
                    _ctrl?.feed(chunk, isFinal: isFinal),
              )
              .catchError((e) {
                DebugLogger.logSimple('AUDIO', '❌ ERROR: $e');
                if (mounted) setState(() => _isRecording = false);
              });
        }
      }
    } catch (e) {
      DebugLogger.logSimple('RECORD', '❌ ERROR: $e');
      if (mounted)
        setState(() {
          _isRecording = false;
        });
    } finally {
      _isToggling = false;
    }
  }

  /// Toggles global voice search across the Quran
  Future<void> _toggleVoiceSearch() async {
    if (_isToggling) return;

    if (_isVoiceSearching) {
      await _stopVoiceSearch();
    } else {
      if (_isRecording) {
        await _toggleRecord();
      }
      await _startVoiceSearch();
    }
  }

  Future<void> _startVoiceSearch() async {
    if (_isVoiceSearching || _isToggling) return;
    _isToggling = true;

    try {
      if (!kIsWeb) {
        final status = await Permission.microphone.request();
        if (status != PermissionStatus.granted) {
          if (mounted) {
            showDialog(
              context: context,
              builder: (ctx) =>
                  const PermissionDialog(reason: PermissionReason.voiceSearch),
            );
          }
          _isToggling = false;
          return;
        }
      }

      if (!_engine.isInitialized) {
        if (mounted) setState(() => _isEngineLoading = true);
        try {
          await _engine.initialize();
        } finally {
          if (mounted) setState(() => _isEngineLoading = false);
        }
      }

      // Suspend highlighting controller so it doesn't consume/reset the engine buffer!
      _ctrl?.finalize();

      // SHOW UI IMMEDIATELY!
      if (mounted) {
        setState(() {
          _isVoiceSearching = true;
          _voiceSearchAsrText = '';
        });
      }

      await _voiceSearchCtrl.startSearch();
      if (!kIsWeb) {
        try { await WakelockPlus.enable(); } catch (_) {}
      }

      _audio
          .start(
            onChunk: (chunk, isFinal) {
              _engine.transcribe(chunk, isFinal: isFinal);
            },
          )
          .catchError((e) {
            DebugLogger.logSimple('AUDIO', '❌ ERROR in Voice Search: $e');
            if (mounted) setState(() => _isVoiceSearching = false);
          });
    } catch (e) {
      DebugLogger.logSimple('VOICE_SEARCH', '❌ START ERROR: $e');
      if (mounted) setState(() => _isVoiceSearching = false);
    } finally {
      _isToggling = false;
    }
  }

  Future<void> _stopVoiceSearch({AnchorResult? precalculatedResult}) async {
    if (!_isVoiceSearching || _isToggling) return;
    _isToggling = true;

    try {
      await _audio.stop();
      _engine.resetBuffer();
      if (!kIsWeb) {
        try { await WakelockPlus.disable(); } catch (_) {}
      }

      if (mounted) {
        setState(() {
          _isVoiceSearching = false;
        });
      }

      AnchorResult? result = precalculatedResult;
      if (result == null) {
        result = await _voiceSearchCtrl.stopSearch(_voiceSearchAsrText);
      }
      if (result != null && _ctrl != null) {
        // Automatically navigate to the found Ayah!
        await _ctrl!.setTargetSurah(result.surah);
        _ctrl!.setManualAyah(result.surah, result.ayah);

        if (mounted) {
          ScaffoldMessenger.of(context).clearSnackBars();
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(
                'تم الانتقال إلى سورة ${result.surah} آية ${result.ayah}',
              ),
              duration: const Duration(seconds: 3),
              behavior: SnackBarBehavior.floating,
              backgroundColor: AppState.instance.colors.gold,
            ),
          );
        }
      } else {
        // Fallback: resume previous state if no Ayah was found
        _ctrl?.resumeTracking();

        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(
              content: Text('لم يتم العثور على الآية، حاول مرة أخرى'),
              duration: Duration(seconds: 2),
            ),
          );
        }
      }
    } catch (e) {
      DebugLogger.logSimple('VOICE_SEARCH', '❌ STOP ERROR: $e');
    } finally {
      _isToggling = false;
    }
  }

  /// Warm, elegant splash screen with Quran ayah.
  Widget _loadingScreen() {
    final ThemeColors c = AppState.instance.colors;
    return Scaffold(
      backgroundColor: c.bg,
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Spacer(flex: 3),

            // ── Decorative top line ──
            Container(
              width: 60,
              height: 1.5,
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  colors: [
                    Colors.transparent,
                    c.gold.withValues(alpha: 0.4),
                    Colors.transparent,
                  ],
                ),
              ),
            ),
            const SizedBox(height: 24),

            // ── Quran Ayah ──
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: FittedBox(
                fit: BoxFit.scaleDown,
                child: Text(
                  'وَلَقَدْ يَسَّرْنَا الْقُرْآنَ لِلذِّكْرِ فَهَلْ مِن مُّدَّكِرٍ',
                  style: TextStyle(
                    fontFamily: 'HafsSmart',
                    color: c.text.withValues(alpha: 0.7),
                    fontSize: 24,
                  ),
                  textAlign: TextAlign.center,
                  textDirection: TextDirection.rtl,
                ),
              ),
            ),

            const SizedBox(height: 24),

            // ── Decorative bottom line ──
            Container(
              width: 60,
              height: 1.5,
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  colors: [
                    Colors.transparent,
                    c.gold.withValues(alpha: 0.4),
                    Colors.transparent,
                  ],
                ),
              ),
            ),

            const Spacer(flex: 2),

            // ── Loading indicator ──
            SizedBox(
              width: 22,
              height: 22,
              child: CircularProgressIndicator(
                strokeWidth: 1.5,
                valueColor: AlwaysStoppedAnimation(
                  c.gold.withValues(alpha: 0.5),
                ),
              ),
            ),
            const SizedBox(height: 12),
            Text(
              _initStatus,
              style: TextStyle(
                color: c.muted.withValues(alpha: 0.5),
                fontSize: 12,
              ),
            ),
            const Spacer(flex: 1),
          ],
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    if (_isInit) return _loadingScreen();
    return TrackingScreen(
      controller: _ctrl!,
      isRecording: _isRecording,
      isLoadingEngine: _isEngineLoading,
      isVoiceSearching: _isVoiceSearching,
      voiceSearchText: _voiceSearchAsrText,
      onToggleRecord: _toggleRecord,
      onVoiceSearchToggle: _toggleVoiceSearch,
      isVoiceSearchLoading: _voiceSearchCtrl.isIndexLoading,
      onClearBuffer: () {
        _engine.resetBuffer();
        _audio.clearBuffer();
      },
    );
  }
}
2
likes
150
points
323
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Real-time on-device Quran recitation tracking, dynamic DTW word alignment, and deterministic Tajweed rule verification engine for Flutter.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, path_provider, record, sherpa_onnx

More

Packages that depend on recite_quran