Skip to main content

mushaf_text 1.1.1 copy "mushaf_text: ^1.1.1" to clipboard
mushaf_text: ^1.1.1 copied to clipboard

The Madinah Mushaf as real text for Flutter — the printed page layout, and tajweed rules coloured letter by letter.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:mushaf_text/mushaf_text.dart';

/// The same reader the Android app ships, built on this package.
///
/// It is kept deliberately close to the Compose screen in `DailySeventy`: the
/// same toolbar order, the same browse sheet with a bottom switch, the same
/// floating focus navigator, and the same rule sheet — including the reference
/// each ruling comes from. Two ports that drift apart stop being one thing, and
/// the tajweed colouring is only worth trusting if both sides say the same.
void main() => runApp(const MushafApp());

class MushafApp extends StatelessWidget {
  const MushafApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'mushaf_text',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6B4F3A)),
          useMaterial3: true,
          // The Android app sets Amiri on every label it draws; leaving the
          // default here is what made the two look like different programs even
          // where they did the same thing.
          fontFamily: 'Amiri',
        ),
        home: const MushafReader(),
      );
}

class MushafReader extends StatefulWidget {
  const MushafReader({super.key});

  @override
  State<MushafReader> createState() => _MushafReaderState();
}

class _MushafReaderState extends State<MushafReader> {
  final _controller = PageController();
  int _page = 1;
  // Off by default, as on Android: a reader who opens the mushaf to read
  // should get the page they know, and turn the colours on deliberately.
  bool _tajweed = false;
  bool _naturalMadd = false;
  bool _dark = false;

  /// The rule being walked through on this page, if any.
  TajweedRule? _focus;
  int _focusIndex = 0;
  List<MapEntry<TajweedRule, int>> _counts = const [];

  MushafColors get _colors => _dark ? MushafColors.dark : MushafColors.light;

  int get _focusTotal => _counts
      .firstWhere((e) => e.key == _focus,
          orElse: () => const MapEntry(TajweedRule.ghunna, 0))
      .value;

  @override
  void initState() {
    super.initState();
    _loadCounts();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _loadCounts() async {
    final page = _page;
    final counts = _tajweed
        ? await pageTajweedCounts(page, includeNaturalMadd: _naturalMadd)
        : const <MapEntry<TajweedRule, int>>[];
    if (!mounted || page != _page) return;
    setState(() => _counts = counts);
  }

  void _onPageChanged(int i) {
    setState(() {
      _page = i + 1;
      // Positions belong to their page, so turning the page ends the walk.
      _focus = null;
      _focusIndex = 0;
    });
    _loadCounts();
  }

  void _setTajweed(bool on) {
    setState(() {
      _tajweed = on;
      if (!on) _focus = null;
    });
    _loadCounts();
  }

  void _setNaturalMadd(bool on) {
    setState(() {
      _naturalMadd = on;
      _focus = null;
    });
    _loadCounts();
  }

  @override
  Widget build(BuildContext context) {
    final colors = _colors;
    return Scaffold(
      backgroundColor: colors.paper,
      appBar: AppBar(
        backgroundColor: colors.paper,
        foregroundColor: colors.accent,
        elevation: 0,
        title: Text(
          'صفحة ${toArabicNumerals(_page)}',
          style: TextStyle(
            fontFamily: mushafFontFamily,
            package: 'mushaf_text',
            color: colors.accent,
            fontSize: 17,
          ),
        ),
        actions: [
          IconButton(
            tooltip: 'التجويد',
            icon: Icon(Icons.palette,
                color: _tajweed ? TajweedRule.idghamGhunna.color : colors.gold),
            onPressed: () => _setTajweed(!_tajweed),
          ),
          IconButton(
            tooltip: 'ليل ونهار',
            icon: Icon(_dark ? Icons.light_mode : Icons.dark_mode,
                color: colors.accent),
            onPressed: () => setState(() => _dark = !_dark),
          ),
          // Last, as on Android: the one button that opens everything else.
          IconButton(
            tooltip: 'الفهرس والأحكام',
            icon: Icon(Icons.menu_book, color: colors.accent),
            onPressed: _showBrowse,
          ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: Stack(
              children: [
                // The mushaf reads right to left, so page 1 sits on the right.
                Directionality(
                  textDirection: TextDirection.rtl,
                  child: PageView.builder(
                    controller: _controller,
                    itemCount: Quran.pageCount,
                    onPageChanged: _onPageChanged,
                    itemBuilder: (context, i) => Padding(
                      padding: const EdgeInsets.symmetric(
                          horizontal: 10, vertical: 8),
                      child: MushafPage(
                        page: i + 1,
                        colors: colors,
                        tajweed: _tajweed,
                        tajweedNaturalMadd: _naturalMadd,
                        focusRule: i + 1 == _page ? _focus : null,
                        focusIndex: _focusIndex,
                        onTajweedTap: _showRule,
                        onAyahTap: (id) => _snack('آية رقم $id'),
                      ),
                    ),
                  ),
                ),
                // Floating, not a row in the column: the page is laid out for
                // fifteen lines, and anything that takes height from it shrinks
                // the text.
                if (_focus != null && _focusTotal > 0)
                  Align(
                    alignment: Alignment.bottomCenter,
                    child: _FocusBar(
                      colors: colors,
                      rule: _focus!,
                      index: _focusIndex,
                      total: _focusTotal,
                      onPrevious: () => setState(() => _focusIndex--),
                      onNext: () => setState(() => _focusIndex++),
                      onClose: () => setState(() => _focus = null),
                    ),
                  ),
              ],
            ),
          ),
          _Bar(
            colors: colors,
            page: _page,
            onJump: (p) {
              _controller.jumpToPage(p - 1);
              _onPageChanged(p - 1);
            },
          ),
        ],
      ),
    );
  }

  void _snack(String text) => ScaffoldMessenger.of(context)
    ..clearSnackBars()
    ..showSnackBar(SnackBar(
      content: Text(text, textDirection: TextDirection.rtl),
      duration: const Duration(seconds: 1),
    ));

  void _focusOn(TajweedRule rule) => setState(() {
        _focus = rule;
        _focusIndex = 0;
      });

  /// The rule on its own, with no word — opened from the colour key.
  void _explain(TajweedRule rule) => showModalBottomSheet<void>(
        context: context,
        backgroundColor: _colors.paper,
        isScrollControlled: true,
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
        ),
        builder: (_) => _RuleSheet(colors: _colors, rule: rule),
      );

  void _showRule(TajweedHit hit) => showModalBottomSheet<void>(
        context: context,
        backgroundColor: _colors.paper,
        isScrollControlled: true,
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
        ),
        builder: (_) => _RuleSheet(
          colors: _colors,
          rule: hit.rule,
          word: hit.word,
          start: hit.start,
          end: hit.end,
          onFollow: () {
            Navigator.pop(context);
            _focusOn(hit.rule);
          },
        ),
      );

  void _showBrowse() => showModalBottomSheet<void>(
        context: context,
        backgroundColor: _colors.paper,
        isScrollControlled: true,
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
        ),
        builder: (_) => _BrowseSheet(
          colors: _colors,
          page: _page,
          counts: _counts,
          tajweed: _tajweed,
          naturalMadd: _naturalMadd,
          onTajweed: (v) {
            _setTajweed(v);
            Navigator.pop(context);
          },
          onNaturalMadd: (v) {
            _setNaturalMadd(v);
            Navigator.pop(context);
          },
          onRule: (rule) {
            Navigator.pop(context);
            _focusOn(rule);
          },
          onExplain: (rule) {
            Navigator.pop(context);
            _explain(rule);
          },
          onSurah: (surah) {
            Navigator.pop(context);
            _controller.jumpToPage(surah.startPage - 1);
            _onPageChanged(surah.startPage - 1);
          },
        ),
      );
}

/// Walks the reader through every occurrence of one rule on the page.
///
/// Tapping a coloured letter stops on the first one and closes the sheet; the
/// arrows move to the next and the previous without reopening anything, and the
/// counter says where the reader is.
class _FocusBar extends StatelessWidget {
  const _FocusBar({
    required this.colors,
    required this.rule,
    required this.index,
    required this.total,
    required this.onPrevious,
    required this.onNext,
    required this.onClose,
  });

  final MushafColors colors;
  final TajweedRule rule;
  final int index;
  final int total;
  final VoidCallback onPrevious;
  final VoidCallback onNext;
  final VoidCallback onClose;

  @override
  Widget build(BuildContext context) {
    final shown = ((index % total) + total) % total + 1;
    final color = colors.tajweedColor(rule);
    // Pinned RTL with arrows that do **not** self-mirror. An auto-mirroring
    // arrow flips with the layout direction, so the one written as "right"
    // draws pointing left — the two buttons then move the opposite way to the
    // way they point.
    return Directionality(
      textDirection: TextDirection.rtl,
      child: Container(
        margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
        padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 5),
        decoration: BoxDecoration(
          color: colors.paper,
          borderRadius: BorderRadius.circular(22),
          border: Border.all(color: color.withValues(alpha: 0.55)),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withValues(alpha: 0.12),
              blurRadius: 10,
              offset: const Offset(0, 3),
            ),
          ],
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            _round(Icons.close, colors.accent, onClose, 17, 'إغلاق'),
            Container(
              width: 11,
              height: 11,
              decoration: BoxDecoration(color: color, shape: BoxShape.circle),
            ),
            const SizedBox(width: 5),
            Text(rule.label,
                style: TextStyle(
                    fontSize: 14, fontWeight: FontWeight.bold, color: color)),
            const SizedBox(width: 7),
            Text('${toArabicNumerals(shown)} من ${toArabicNumerals(total)}',
                style: TextStyle(fontSize: 12.5, color: colors.accent)),
            const SizedBox(width: 3),
            // The mushaf reads right to left, so "previous" points right and
            // "next" points left — the way the eye moves along the line.
            _round(Icons.keyboard_arrow_right, color, onPrevious, 22,
                'الموضع السابق'),
            _round(Icons.keyboard_arrow_left, color, onNext, 22,
                'الموضع التالي'),
          ],
        ),
      ),
    );
  }

  Widget _round(IconData icon, Color tint, VoidCallback onTap, double size,
          String label) =>
      Semantics(
        button: true,
        label: label,
        child: InkResponse(
          onTap: onTap,
          radius: 22,
          child: SizedBox(
            width: 34,
            height: 34,
            child: Icon(icon, color: tint, size: size),
          ),
        ),
      );
}

/// What a tapped letter means, and where the ruling comes from.
class _RuleSheet extends StatelessWidget {
  const _RuleSheet({
    required this.colors,
    required this.rule,
    this.word,
    this.start = 0,
    this.end = 0,
    this.onFollow,
  });

  final MushafColors colors;
  final TajweedRule rule;

  /// The word the reader tapped, if they got here by tapping a letter. Null
  /// when the sheet was opened from the colour key, which has no word to show.
  final String? word;
  final int start;
  final int end;
  final VoidCallback? onFollow;

  @override
  Widget build(BuildContext context) {
    final color = colors.tajweedColor(rule);
    final word = this.word;
    final a = word == null ? 0 : start.clamp(0, word.length);
    final b = word == null ? 0 : end.clamp(a, word.length);
    final follow = onFollow;
    return Directionality(
      textDirection: TextDirection.rtl,
      child: DraggableScrollableSheet(
        expand: false,
        initialChildSize: 0.72,
        maxChildSize: 0.95,
        builder: (context, scroll) => ListView(
          controller: scroll,
          padding: const EdgeInsets.fromLTRB(20, 18, 20, 30),
          children: [
            Row(children: [
              Container(
                width: 14,
                height: 14,
                decoration: BoxDecoration(
                  color: color,
                  borderRadius: BorderRadius.circular(4),
                ),
              ),
              const SizedBox(width: 10),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(rule.label,
                        style: TextStyle(
                            fontSize: 21,
                            fontWeight: FontWeight.bold,
                            color: color)),
                    Text(rule.family.label,
                        style: TextStyle(fontSize: 12.5, color: colors.accent)),
                  ],
                ),
              ),
              if (follow != null)
                TextButton.icon(
                  onPressed: follow,
                  icon: const Icon(Icons.travel_explore, size: 18),
                  label: const Text('تتبّعه'),
                ),
            ]),
            const SizedBox(height: 16),
            // The reader's own word, with the letter carrying the rule picked
            // out — an example they found beats a canned one. Opened from the
            // colour key there is no such word, and a canned one would be worse
            // than none, so the box is simply left out.
            if (word != null)
            Container(
              width: double.infinity,
              padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
              decoration: BoxDecoration(
                color: colors.banner,
                border: Border.all(color: colors.gold),
                borderRadius: BorderRadius.circular(14),
              ),
              child: Text.rich(
                TextSpan(
                  style: TextStyle(
                    fontFamily: mushafFontFamily,
                    package: 'mushaf_text',
                    fontSize: 34,
                    color: colors.ink,
                  ),
                  children: [
                    TextSpan(text: word.substring(0, a)),
                    TextSpan(
                        text: word.substring(a, b),
                        style: TextStyle(color: color)),
                    TextSpan(text: word.substring(b)),
                  ],
                ),
                textAlign: TextAlign.center,
              ),
            ),
            const SizedBox(height: 18),
            _row(colors, 'التعريف', rule.definition),
            _row(colors, 'المقدار', rule.amount),
            _row(colors, 'الحروف', rule.letters),
            _Source(colors: colors, rule: rule),
          ],
        ),
      ),
    );
  }
}

Widget _row(MushafColors colors, String label, String value) => Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 68,
            child: Text(label,
                style: TextStyle(fontSize: 13, color: colors.accent)),
          ),
          Expanded(
            child: Text(value,
                style: TextStyle(fontSize: 15, height: 1.7, color: colors.ink)),
          ),
        ],
      ),
    );

/// The reference a ruling is taken from, in its own words.
///
/// Naming the book alone asks the reader to trust and move on. The verse itself
/// lets them check it against what they memorised or against their teacher, and
/// catch our mistake if we made one — the difference between software that says
/// "trust me" and software that says "check me".
class _Source extends StatelessWidget {
  const _Source({required this.colors, required this.rule});

  final MushafColors colors;
  final TajweedRule rule;

  @override
  Widget build(BuildContext context) => Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Divider(color: colors.gold.withValues(alpha: 0.4)),
          const SizedBox(height: 8),
          Text('المرجع', style: TextStyle(fontSize: 13, color: colors.accent)),
          const SizedBox(height: 6),
          Text(rule.source,
              style: TextStyle(
                  fontSize: 14,
                  fontWeight: FontWeight.bold,
                  height: 1.6,
                  color: colors.ink)),
          const SizedBox(height: 8),
          Container(
            width: double.infinity,
            padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
            decoration: BoxDecoration(
              color: colors.banner.withValues(alpha: 0.6),
              borderRadius: BorderRadius.circular(12),
            ),
            child: Text(
              // Verses are separated by ‖ and their two halves by *. Splitting
              // on the first makes it read as poetry rather than one long line.
              rule.evidence.replaceAll(' ‖ ', '\n'),
              textAlign: TextAlign.center,
              style: TextStyle(
                  fontSize: 15,
                  height: 2.0,
                  color: colors.ink.withValues(alpha: 0.9)),
            ),
          ),
        ],
      );
}

/// The surah index and this page's rules behind one button.
///
/// On Android these were two sheets reached from two toolbar icons, and the
/// toolbar got crowded enough to push the page down — which shrinks the text,
/// because the page sizes itself to the height it is given. One button with a
/// bottom switch keeps both a tap away and gives the page its height back.
class _BrowseSheet extends StatefulWidget {
  const _BrowseSheet({
    required this.colors,
    required this.page,
    required this.counts,
    required this.tajweed,
    required this.naturalMadd,
    required this.onTajweed,
    required this.onNaturalMadd,
    required this.onRule,
    required this.onExplain,
    required this.onSurah,
  });

  final MushafColors colors;
  final int page;
  final List<MapEntry<TajweedRule, int>> counts;
  final bool tajweed;
  final bool naturalMadd;
  final ValueChanged<bool> onTajweed;
  final ValueChanged<bool> onNaturalMadd;
  final ValueChanged<TajweedRule> onRule;
  final ValueChanged<TajweedRule> onExplain;
  final ValueChanged<Surah> onSurah;

  @override
  State<_BrowseSheet> createState() => _BrowseSheetState();
}

class _BrowseSheetState extends State<_BrowseSheet> {
  int _tab = 0;

  @override
  Widget build(BuildContext context) {
    final colors = widget.colors;
    return Directionality(
      textDirection: TextDirection.rtl,
      child: DraggableScrollableSheet(
        expand: false,
        initialChildSize: 0.85,
        maxChildSize: 0.95,
        builder: (context, scroll) => Column(
          children: [
            Expanded(
              child:
                  _tab == 0 ? _index(scroll, colors) : _rules(scroll, colors),
            ),
            _BrowseBar(
              colors: colors,
              selected: _tab,
              onSelect: (i) => setState(() => _tab = i),
            ),
          ],
        ),
      ),
    );
  }

  Widget _index(ScrollController scroll, MushafColors colors) =>
      ListView.builder(
        controller: scroll,
        padding: const EdgeInsets.fromLTRB(12, 14, 12, 8),
        itemCount: Quran.surahs.length,
        itemBuilder: (context, i) {
          final s = Quran.surahs[i];
          return ListTile(
            dense: true,
            leading: Text(toArabicNumerals(s.number),
                style: TextStyle(color: colors.accent)),
            title: Text(s.nameArabic, style: TextStyle(color: colors.ink)),
            trailing: Text('ص ${toArabicNumerals(s.startPage)}',
                style: TextStyle(fontSize: 12, color: colors.accent)),
            onTap: () => widget.onSurah(s),
          );
        },
      );

  Widget _rules(ScrollController scroll, MushafColors colors) {
    final counts = widget.counts;
    return ListView(
      controller: scroll,
      padding: const EdgeInsets.fromLTRB(20, 14, 20, 8),
      children: [
        SwitchListTile(
          contentPadding: EdgeInsets.zero,
          title: const Text('تلوين الأحكام'),
          value: widget.tajweed,
          onChanged: widget.onTajweed,
        ),
        SwitchListTile(
          contentPadding: EdgeInsets.zero,
          title: const Text('المدّ الطبيعي'),
          subtitle: const Text('أكثر حكم تكرارًا — تلوينه يصبغ الصفحة'),
          value: widget.naturalMadd,
          onChanged: widget.tajweed ? widget.onNaturalMadd : null,
        ),
        const Divider(),
        Padding(
          padding: const EdgeInsets.only(top: 10, bottom: 2),
          child: Text('أحكام صفحة ${toArabicNumerals(widget.page)}',
              style: TextStyle(
                  fontSize: 15,
                  fontWeight: FontWeight.bold,
                  color: colors.ink)),
        ),
        Padding(
          padding: const EdgeInsets.only(bottom: 8),
          child: Text('اضغط أي حكم يوقّفك على أول موضع له في الصفحة.',
              style: TextStyle(fontSize: 12.5, color: colors.accent)),
        ),
        if (!widget.tajweed)
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 18),
            child: Text('شغّل تلوين الأحكام لتظهر هنا',
                style: TextStyle(fontSize: 13, color: colors.accent)),
          )
        else if (counts.isEmpty)
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 18),
            child: Text('لا أحكام على هذه الصفحة',
                style: TextStyle(fontSize: 13, color: colors.accent)),
          )
        else
          // Tapping a rule starts the walk through its positions on this page,
          // which is why the count is here and not just a colour key.
          for (final e in counts)
            ListTile(
              contentPadding: EdgeInsets.zero,
              dense: true,
              leading: Container(
                width: 12,
                height: 12,
                decoration: BoxDecoration(
                  color: colors.tajweedColor(e.key),
                  borderRadius: BorderRadius.circular(3),
                ),
              ),
              title: Text(e.key.label, style: TextStyle(color: colors.ink)),
              trailing: Text('${toArabicNumerals(e.value)} موضع',
                  style: TextStyle(fontSize: 12.5, color: colors.accent)),
              onTap: () => widget.onRule(e.key),
            ),
        const SizedBox(height: 18),
        Divider(color: colors.gold.withValues(alpha: 0.4)),
        const SizedBox(height: 14),
        // The whole key, grouped by family — not only what this page happens to
        // carry. A reader looking up a rule they met elsewhere should find it
        // here without hunting for a page that has one.
        Text('كل الأحكام',
            style: TextStyle(
                fontSize: 15, fontWeight: FontWeight.bold, color: colors.ink)),
        Padding(
          padding: const EdgeInsets.only(top: 2, bottom: 10),
          child: Text('اضغط على أي حكم لشرحه ومرجعه، أو على أي حرف ملوّن في الصفحة.',
              style: TextStyle(fontSize: 12.5, color: colors.accent)),
        ),
        for (final family in TajweedFamily.values) ...[
          Padding(
            padding: const EdgeInsets.only(top: 6, bottom: 2),
            child: Text(family.label,
                style: TextStyle(fontSize: 12, color: colors.accent)),
          ),
          for (final rule
              in TajweedRule.values.where((r) => r.family == family))
            ListTile(
              contentPadding: EdgeInsets.zero,
              dense: true,
              leading: Container(
                width: 12,
                height: 12,
                decoration: BoxDecoration(
                  color: colors.tajweedColor(rule),
                  borderRadius: BorderRadius.circular(3),
                ),
              ),
              title: Text(rule.label, style: TextStyle(color: colors.ink)),
              trailing: Text(rule.amount,
                  style: TextStyle(fontSize: 12, color: colors.accent)),
              onTap: () => widget.onExplain(rule),
            ),
          const SizedBox(height: 8),
        ],
        Divider(color: colors.gold.withValues(alpha: 0.4)),
        Padding(
          padding: const EdgeInsets.only(top: 12, bottom: 8),
          child: Text(
            'مواضع الأحكام مستخرجة من رسم المصحف نفسه، ومقابَلة على تحفة '
            'الأطفال والمقدمة الجزرية. المس أي حرف ملوّن لترى الحكم ومرجعه.',
            style: TextStyle(fontSize: 12, height: 1.8, color: colors.accent),
          ),
        ),
      ],
    );
  }
}

/// The switch between the index and the rules, drawn to match the Compose one.
///
/// Material's `NavigationBar` brings its own pill indicator, its own height and
/// its own type — all of which read as a different app next to the Android
/// screen. This is the same two columns Compose draws: icon, gap, label, and
/// nothing else.
class _BrowseBar extends StatelessWidget {
  const _BrowseBar({
    required this.colors,
    required this.selected,
    required this.onSelect,
  });

  final MushafColors colors;
  final int selected;
  final ValueChanged<int> onSelect;

  static const _tabs = [
    (icon: Icons.list, label: 'الفهرس'),
    (icon: Icons.palette, label: 'الأحكام'),
  ];

  @override
  Widget build(BuildContext context) {
    final primary = Theme.of(context).colorScheme.primary;
    return Container(
      color: colors.paper,
      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
      child: SafeArea(
        top: false,
        child: Row(
          children: [
            for (var i = 0; i < _tabs.length; i++)
              Expanded(
                child: InkWell(
                  onTap: () => onSelect(i),
                  borderRadius: BorderRadius.circular(12),
                  child: Padding(
                    padding: const EdgeInsets.symmetric(vertical: 7),
                    child: Column(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Icon(
                          _tabs[i].icon,
                          size: 21,
                          color: i == selected
                              ? primary
                              : colors.accent.withValues(alpha: 0.55),
                        ),
                        const SizedBox(height: 3),
                        Text(
                          _tabs[i].label,
                          style: TextStyle(
                            fontSize: 11.5,
                            fontWeight: i == selected
                                ? FontWeight.bold
                                : FontWeight.normal,
                            color: i == selected
                                ? primary
                                : colors.accent.withValues(alpha: 0.7),
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

/// Page number and a jump slider.
class _Bar extends StatelessWidget {
  const _Bar({required this.colors, required this.page, required this.onJump});

  final MushafColors colors;
  final int page;
  final void Function(int) onJump;

  @override
  Widget build(BuildContext context) {
    return Directionality(
      textDirection: TextDirection.rtl,
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
        decoration: BoxDecoration(
          color: colors.paper,
          border:
              Border(top: BorderSide(color: colors.gold.withValues(alpha: 0.5))),
        ),
        child: Row(
          children: [
            Expanded(
              child: Slider(
                value: page.toDouble(),
                min: 1,
                max: Quran.pageCount.toDouble(),
                onChanged: (v) => onJump(v.round()),
              ),
            ),
            Text(
              '${toArabicNumerals(page)} / ${toArabicNumerals(Quran.pageCount)}',
              style: TextStyle(fontSize: 12, color: colors.accent),
            ),
          ],
        ),
      ),
    );
  }
}
1
likes
0
points
512
downloads

Publisher

unverified uploader

Weekly Downloads

The Madinah Mushaf as real text for Flutter — the printed page layout, and tajweed rules coloured letter by letter.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter

More

Packages that depend on mushaf_text