sakura_epub 0.1.3 copy "sakura_epub: ^0.1.3" to clipboard
sakura_epub: ^0.1.3 copied to clipboard

A full-featured Flutter EPUB reader package. Render EPUB 2 & 3 books with annotations, search, themes, and programmatic navigation — powered by epub.js.

example/lib/main.dart

import 'dart:convert';
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sakura_epub/sakura_epub.dart';
import 'package:iconly/iconly.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
  runApp(const SakuraApp());
}

// ── Theme data ──────────────────────────────────────────────────────────────

class _ThemeOption {
  final EpubThemeType type;
  final String label;
  final Color bg;
  final Color fg;
  final Color swatch;
  final Color accent;

  const _ThemeOption({
    required this.type,
    required this.label,
    required this.bg,
    required this.fg,
    required this.swatch,
    required this.accent,
  });
}

const _themes = [
  _ThemeOption(
    type: EpubThemeType.light,
    label: 'Light',
    bg: Color(0xffffffff),
    fg: Color(0xff1a1a1a),
    swatch: Color(0xfff5f5f5),
    accent: Color(0xffE5989B),
  ),
  _ThemeOption(
    type: EpubThemeType.sepia,
    label: 'Sepia',
    bg: Color(0xfff4ecd8),
    fg: Color(0xff5b4636),
    swatch: Color(0xfff4ecd8),
    accent: Color(0xffB5838D),
  ),
  _ThemeOption(
    type: EpubThemeType.tan,
    label: 'Tan',
    bg: Color(0xffdfd4b8),
    fg: Color(0xff3e3025),
    swatch: Color(0xffdfd4b8),
    accent: Color(0xff6D6875),
  ),
  _ThemeOption(
    type: EpubThemeType.mint,
    label: 'Mint',
    bg: Color(0xffe8f5e9),
    fg: Color(0xff2d3e2d),
    swatch: Color(0xffe8f5e9),
    accent: Color(0xff4A6D4A),
  ),
  _ThemeOption(
    type: EpubThemeType.grey,
    label: 'Grey',
    bg: Color(0xff333333),
    fg: Color(0xffcccccc),
    swatch: Color(0xff444444),
    accent: Color(0xffE5989B),
  ),
  _ThemeOption(
    type: EpubThemeType.dark,
    label: 'Dark',
    bg: Color(0xff121212),
    fg: Color(0xffe0e0e0),
    swatch: Color(0xff1e1e1e),
    accent: Color(0xffB5838D),
  ),
];

_ThemeOption _optionFor(EpubThemeType t) =>
    _themes.firstWhere((o) => o.type == t, orElse: () => _themes.first);
const _fonts = [
  'NewYork',
  'Gilroy',
  'Alegreya',
  'Amazon Ember',
  'Atkinson Hyperlegible',
  'Bitter Pro',
  'Bookerly',
  'Droid Sans',
  'EB Garamond',
  'Gentium Book Plus',
  'Halant',
  'IBM Plex Sans',
  'LinLibertine',
  'Literata',
  'Lora',
  'Ubuntu',
];

/// Maps font family name to its asset filename in the sakura_epub package.
const _fontFileMap = {
  'NewYork': 'NewYork.ttf',
  'Gilroy': 'Gilroy-Medium.ttf',
  'Alegreya': 'Alegreya.ttf',
  'Amazon Ember': 'Amazon-Ember-Regular.ttf',
  'Atkinson Hyperlegible': 'AtkinsonHyperlegible-Regular.ttf',
  'Bitter Pro': 'BitterPro-Regular.ttf',
  'Bookerly': 'Bookerly.ttf',
  'Droid Sans': 'DroidSans.ttf',
  'EB Garamond': 'EBGaramond-Var.ttf',
  'Gentium Book Plus': 'GentiumBookPlus-Regular.ttf',
  'Halant': 'Halant-Regular.ttf',
  'IBM Plex Sans': 'IBMPlexSans-Regular.ttf',
  'LinLibertine': 'LinLibertine-Regular.ttf',
  'Literata': 'Literata-Var.ttf',
  'Lora': 'Lora-Var.ttf',
  'Ubuntu': 'Ubuntu-Var.ttf',
};

EpubTheme _epubThemeFor(EpubThemeType t, String font) {
  final customCss = {
    'body': {
      'font-family': "'$font', sans-serif !important",
    },
  };

  switch (t) {
    case EpubThemeType.dark:
      return EpubTheme.dark()..customCss = customCss;
    case EpubThemeType.sepia:
      return EpubTheme.sepia()..customCss = customCss;
    case EpubThemeType.tan:
      return EpubTheme.tan()..customCss = customCss;
    case EpubThemeType.grey:
      return EpubTheme.grey()..customCss = customCss;
    case EpubThemeType.mint:
      return EpubTheme.mint()..customCss = customCss;
    default:
      return EpubTheme.light()..customCss = customCss;
  }
}

// ── App root ─────────────────────────────────────────────────────────────────

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

  @override
  State<SakuraApp> createState() => _SakuraAppState();
}

class _SakuraAppState extends State<SakuraApp> {
  EpubThemeType _themeType = EpubThemeType.light;
  String _fontFamily = 'Gilroy';

  void _onSettingsChanged(EpubThemeType t, String f) {
    setState(() {
      _themeType = t;
      _fontFamily = f;
    });
  }

  @override
  Widget build(BuildContext context) {
    final opt = _optionFor(_themeType);
    final isDark =
        _themeType == EpubThemeType.dark || _themeType == EpubThemeType.grey;

    return MaterialApp(
      title: 'Sakura Reader',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: opt.accent,
          brightness: isDark ? Brightness.dark : Brightness.light,
          surface: isDark ? const Color(0xff1c1c1e) : Colors.white,
        ),
        fontFamily: _fontFamily,
        tooltipTheme: TooltipThemeData(
          decoration: BoxDecoration(
            color: opt.accent.withValues(alpha: 0.85),
            borderRadius: BorderRadius.circular(8),
          ),
          textStyle: const TextStyle(color: Colors.white, fontSize: 12),
        ),
      ),
      home: ReaderPage(
        themeType: _themeType,
        fontFamily: _fontFamily,
        onSettingsChanged: _onSettingsChanged,
      ),
    );
  }
}

// ── Reader page ───────────────────────────────────────────────────────────────

class ReaderPage extends StatefulWidget {
  final EpubThemeType themeType;
  final String fontFamily;
  final void Function(EpubThemeType, String) onSettingsChanged;

  const ReaderPage({
    super.key,
    required this.themeType,
    required this.fontFamily,
    required this.onSettingsChanged,
  });

  @override
  State<ReaderPage> createState() => _ReaderPageState();
}

class _ReaderPageState extends State<ReaderPage>
    with SingleTickerProviderStateMixin {
  final EpubController _ctrl = EpubController();
  final EpubSource _src =
      EpubSource.fromAsset('assets/herbert-old-rambling-house.epub');

  // State
  bool _loaded = false;
  bool _barsVisible = true;
  List<EpubChapter> _chapters = [];
  EpubLocation? _location;
  double _fontSize = 18;
  String _selectedText = '';
  String _selectedCfi = '';

  late final AnimationController _barAnim;
  late final Animation<double> _barFade;

  @override
  void initState() {
    super.initState();
    _barAnim = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 220),
      value: 1,
    );
    _barFade = CurvedAnimation(parent: _barAnim, curve: Curves.easeInOut);
  }

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

  _ThemeOption get _opt => _optionFor(widget.themeType);
  bool get _isDark =>
      widget.themeType == EpubThemeType.dark ||
      widget.themeType == EpubThemeType.grey;

  void _toggleBars() {
    if (_barsVisible) {
      _barAnim.reverse().then((_) => setState(() => _barsVisible = false));
    } else {
      setState(() => _barsVisible = true);
      _barAnim.forward();
    }
  }

  void _switchTheme(EpubThemeType t) {
    widget.onSettingsChanged(t, widget.fontFamily);
    _ctrl.updateTheme(theme: _epubThemeFor(t, widget.fontFamily));
  }

  void _changeFontSize(double v) {
    setState(() => _fontSize = v);
    _ctrl.setFontSize(fontSize: v);
  }

  void _changeFont(String font) {
    widget.onSettingsChanged(widget.themeType, font);
    _ctrl.updateTheme(theme: _epubThemeFor(widget.themeType, font));
    _loadAndSetFont(font);
  }

  Future<void> _loadAndSetFont(String fontFamily) async {
    final fileName = _fontFileMap[fontFamily];
    if (fileName == null) return;
    try {
      final data = await rootBundle.load(
        'packages/sakura_epub/lib/assets/fonts/$fileName',
      );
      final base64 = base64Encode(data.buffer.asUint8List());
      final mime = fileName.endsWith('.otf') ? 'font/opentype' : 'font/truetype';
      await _ctrl.setFontFamily(
        fontFamily: fontFamily,
        fontBase64: base64,
        fontMimeType: mime,
      );
    } catch (_) {
      // Font asset not found – apply family name only
      await _ctrl.setFontFamily(fontFamily: fontFamily);
    }
  }

  void _openSettings() {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (_) => _SettingsSheet(
        themeType: widget.themeType,
        fontSize: _fontSize,
        fontFamily: widget.fontFamily,
        onTheme: _switchTheme,
        onFontSize: _changeFontSize,
        onFont: _changeFont,
        isDark: _isDark,
      ),
    );
  }

  void _openChapters() {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (_) => _ChapterSheet(
        chapters: _chapters,
        isDark: _isDark,
        onTap: (href) {
          Navigator.pop(context);
          _ctrl.display(cfi: href);
        },
      ),
    );
  }

  void _showSearch() async {
    final query = await showModalBottomSheet<String>(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (_) => _SearchSheet(isDark: _isDark),
    );
    if (query == null || query.isEmpty || !_loaded) return;

    final results = await _ctrl.search(query: query);
    if (!mounted) return;

    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (_) => _SearchResultsSheet(
        query: query,
        results: results,
        isDark: _isDark,
        onTap: (cfi) {
          Navigator.pop(context);
          _ctrl.display(cfi: cfi);
        },
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final opt = _opt;
    final bgColor = opt.bg;
    final fgColor = opt.fg;
    final overlayBg = _isDark
        ? Colors.black.withValues(alpha: 0.75)
        : Colors.white.withValues(alpha: 0.88);
    final overlayFg = _isDark ? Colors.white : const Color(0xff1a1a1a);

    return AnnotatedRegion<SystemUiOverlayStyle>(
      value: _isDark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
      child: Scaffold(
        backgroundColor: bgColor,
        body: SafeArea(
          child: Stack(
            children: [
              // ── EPUB viewer (full screen) ──────────────────────────────────
              Positioned.fill(
                child: EpubViewer(
                  epubController: _ctrl,
                  epubSource: _src,
                  displaySettings: EpubDisplaySettings(
                    fontSize: _fontSize.toInt(),
                    flow: EpubFlow.paginated,
                    spread: EpubSpread.none,
                    snap: true,
                    allowScriptedContent: true,
                    theme: _epubThemeFor(widget.themeType, widget.fontFamily),
                  ),
                  onEpubLoaded: () {
                    setState(() => _loaded = true);
                    _loadAndSetFont(widget.fontFamily);
                  },
                  onChaptersLoaded: (ch) => setState(() => _chapters = ch),
                  onRelocated: (loc) => setState(() => _location = loc),
                  onTextSelected: (sel) => setState(() {
                    _selectedText = sel.selectedText;
                    _selectedCfi = sel.selectionCfi;
                  }),
                  onDeselection: () => setState(() {
                    _selectedText = '';
                    _selectedCfi = '';
                  }),
                  onTouchUp: (x, y) {
                    if (_loaded) _toggleBars();
                  },
                ),
              ),

              // ── Loading overlay ────────────────────────────────────────────
              if (!_loaded)
                Positioned.fill(
                  child: _LoadingOverlay(bgColor: bgColor, fgColor: fgColor),
                ),

              // ── Top bar ────────────────────────────────────────────────────
              if (_loaded && _barsVisible)
                Positioned(
                  top: 0,
                  left: 0,
                  right: 0,
                  child: FadeTransition(
                    opacity: _barFade,
                    child: _TopBar(
                      overlayBg: overlayBg,
                      overlayFg: overlayFg,
                      isDark: _isDark,
                      hasChapters: _chapters.isNotEmpty,
                      onChapters: _openChapters,
                      onSearch: _showSearch,
                      onSettings: _openSettings,
                    ),
                  ),
                ),

              // ── Selection toolbar ──────────────────────────────────────────
              if (_selectedText.isNotEmpty && _loaded)
                Positioned(
                  bottom: 90,
                  left: 16,
                  right: 16,
                  child: _SelectionBar(
                    text: _selectedText,
                    overlayBg: overlayBg,
                    overlayFg: overlayFg,
                    onHighlight: () {
                      _ctrl.addHighlight(
                        cfi: _selectedCfi,
                        color: Theme.of(context).colorScheme.primary,
                        opacity: 0.45,
                      );
                      _ctrl.clearSelection();
                      setState(() {
                        _selectedText = '';
                        _selectedCfi = '';
                      });
                    },
                    onClear: () {
                      _ctrl.clearSelection();
                      setState(() {
                        _selectedText = '';
                        _selectedCfi = '';
                      });
                    },
                  ),
                ),

              // ── Bottom bar ─────────────────────────────────────────────────
              if (_loaded && _barsVisible)
                Positioned(
                  bottom: 24 + MediaQuery.of(context).padding.bottom,
                  left: 20,
                  right: 20,
                  child: FadeTransition(
                    opacity: _barFade,
                    child: ScaleTransition(
                      scale: _barFade,
                      child: _BottomBar(
                        overlayBg: overlayBg,
                        overlayFg: overlayFg,
                        isDark: _isDark,
                        location: _location,
                        onPrev: () => _ctrl.prev(),
                        onNext: () => _ctrl.next(),
                      ),
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

// ── Loading overlay ───────────────────────────────────────────────────────────

class _LoadingOverlay extends StatefulWidget {
  final Color bgColor;
  final Color fgColor;

  const _LoadingOverlay({required this.bgColor, required this.fgColor});

  @override
  State<_LoadingOverlay> createState() => _LoadingOverlayState();
}

class _LoadingOverlayState extends State<_LoadingOverlay>
    with SingleTickerProviderStateMixin {
  late final AnimationController _anim;
  late final Animation<double> _pulse;

  @override
  void initState() {
    super.initState();
    _anim = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1400),
    )..repeat(reverse: true);
    _pulse = CurvedAnimation(parent: _anim, curve: Curves.easeInOut);
  }

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: widget.bgColor,
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            AnimatedBuilder(
              animation: _pulse,
              builder: (context, child) {
                return Opacity(
                  opacity: 0.3 + (_pulse.value * 0.4),
                  child: Transform.scale(
                    scale: 0.95 + (_pulse.value * 0.1),
                    child: child,
                  ),
                );
              },
              child: Container(
                padding: const EdgeInsets.all(24),
                decoration: BoxDecoration(
                  color: widget.fgColor.withValues(alpha: 0.04),
                  shape: BoxShape.circle,
                  border: Border.all(
                    color: widget.fgColor.withValues(alpha: 0.08),
                    width: 1,
                  ),
                ),
                child: Icon(
                  IconlyLight.document,
                  size: 52,
                  color: widget.fgColor.withValues(alpha: 0.6),
                ),
              ),
            ),
            const SizedBox(height: 32),
            SizedBox(
              width: 140,
              child: LinearProgressIndicator(
                backgroundColor: widget.fgColor.withValues(alpha: 0.06),
                color: widget.fgColor.withValues(alpha: 0.25),
                borderRadius: BorderRadius.circular(10),
                minHeight: 3,
              ),
            ),
            const SizedBox(height: 20),
            Text(
              'Opening your book…',
              style: TextStyle(
                color: widget.fgColor.withValues(alpha: 0.4),
                fontSize: 14,
                letterSpacing: 0.2,
                fontWeight: FontWeight.w500,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Top bar ───────────────────────────────────────────────────────────────────

class _TopBar extends StatelessWidget {
  final Color overlayBg;
  final Color overlayFg;
  final bool isDark;
  final bool hasChapters;
  final VoidCallback onChapters;
  final VoidCallback onSearch;
  final VoidCallback onSettings;

  const _TopBar({
    required this.overlayBg,
    required this.overlayFg,
    required this.isDark,
    required this.hasChapters,
    required this.onChapters,
    required this.onSearch,
    required this.onSettings,
  });

  @override
  Widget build(BuildContext context) {
    return ClipRect(
      child: BackdropFilter(
        filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
        child: Container(
          color: overlayBg,
          padding: EdgeInsets.only(
            top: MediaQuery.of(context).padding.top + 4,
            bottom: 8,
            left: 8,
            right: 8,
          ),
          child: Row(
            children: [
              const Spacer(),
              if (hasChapters)
                _BarButton(
                  icon: IconlyLight.category,
                  color: overlayFg,
                  onTap: onChapters,
                ),
              _BarButton(
                icon: IconlyLight.search,
                color: overlayFg,
                onTap: onSearch,
              ),
              _BarButton(
                icon: IconlyLight.setting,
                color: overlayFg,
                onTap: onSettings,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

// ── Bottom bar ────────────────────────────────────────────────────────────────

class _BottomBar extends StatelessWidget {
  final Color overlayBg;
  final Color overlayFg;
  final bool isDark;
  final EpubLocation? location;
  final VoidCallback onPrev;
  final VoidCallback onNext;

  const _BottomBar({
    required this.overlayBg,
    required this.overlayFg,
    required this.isDark,
    required this.location,
    required this.onPrev,
    required this.onNext,
  });

  @override
  Widget build(BuildContext context) {
    final progress = location?.progress ?? 0.0;
    final percent = (progress * 100).toStringAsFixed(0);

    return ClipRRect(
      borderRadius: BorderRadius.circular(24),
      child: BackdropFilter(
        filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 18),
          decoration: BoxDecoration(
            color: overlayBg,
            borderRadius: BorderRadius.circular(24),
            border: Border.all(
              color: overlayFg.withValues(alpha: 0.08),
              width: 1,
            ),
            boxShadow: [
              BoxShadow(
                color: Colors.black.withValues(alpha: 0.1),
                blurRadius: 20,
                offset: const Offset(0, 8),
              ),
            ],
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              // Progress track
              Row(
                children: [
                  Text(
                    '$percent%',
                    style: TextStyle(
                      color: overlayFg.withValues(alpha: 0.5),
                      fontSize: 11,
                      fontWeight: FontWeight.w700,
                      letterSpacing: 0.8,
                    ),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(10),
                      child: LinearProgressIndicator(
                        value: progress,
                        minHeight: 4,
                        backgroundColor: overlayFg.withValues(alpha: 0.08),
                        color: overlayFg.withValues(alpha: 0.4),
                      ),
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 16),
              // Navigation row
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  _NavButton(
                    icon: IconlyLight.arrow_left,
                    label: 'Previous',
                    fgColor: overlayFg,
                    onTap: onPrev,
                  ),
                  _NavButton(
                    icon: IconlyLight.arrow_right,
                    label: 'Next',
                    fgColor: overlayFg,
                    onTap: onNext,
                    iconRight: true,
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

// ── Selection bar ─────────────────────────────────────────────────────────────

class _SelectionBar extends StatelessWidget {
  final String text;
  final Color overlayBg;
  final Color overlayFg;
  final VoidCallback onHighlight;
  final VoidCallback onClear;

  const _SelectionBar({
    required this.text,
    required this.overlayBg,
    required this.overlayFg,
    required this.onHighlight,
    required this.onClear,
  });

  @override
  Widget build(BuildContext context) {
    return ClipRRect(
      borderRadius: BorderRadius.circular(20),
      child: BackdropFilter(
        filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25),
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
          decoration: BoxDecoration(
            color: overlayBg,
            borderRadius: BorderRadius.circular(20),
            border: Border.all(
              color: overlayFg.withValues(alpha: 0.08),
              width: 1,
            ),
            boxShadow: [
              BoxShadow(
                color: Colors.black.withValues(alpha: 0.15),
                blurRadius: 30,
                offset: const Offset(0, 10),
              ),
            ],
          ),
          child: Row(
            children: [
              Container(
                padding: const EdgeInsets.all(8),
                decoration: BoxDecoration(
                  color: overlayFg.withValues(alpha: 0.05),
                  shape: BoxShape.circle,
                ),
                child: Icon(
                  IconlyLight.edit,
                  size: 16,
                  color: overlayFg.withValues(alpha: 0.6),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: Text(
                  '"$text"',
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: TextStyle(
                    color: overlayFg.withValues(alpha: 0.8),
                    fontSize: 13,
                    fontStyle: FontStyle.italic,
                    letterSpacing: -0.2,
                  ),
                ),
              ),
              const SizedBox(width: 12),
              _PillButton(
                label: 'Highlight',
                bgColor: Theme.of(context).colorScheme.primary,
                fgColor: Colors.white,
                onTap: onHighlight,
              ),
              const SizedBox(width: 8),
              _BarButton(
                icon: IconlyLight.close_square,
                color: overlayFg.withValues(alpha: 0.5),
                onTap: onClear,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

// ── Settings bottom sheet ─────────────────────────────────────────────────────

class _SettingsSheet extends StatefulWidget {
  final EpubThemeType themeType;
  final double fontSize;
  final String fontFamily;
  final ValueChanged<EpubThemeType> onTheme;
  final ValueChanged<double> onFontSize;
  final ValueChanged<String> onFont;
  final bool isDark;

  const _SettingsSheet({
    required this.themeType,
    required this.fontSize,
    required this.fontFamily,
    required this.onTheme,
    required this.onFontSize,
    required this.onFont,
    required this.isDark,
  });

  @override
  State<_SettingsSheet> createState() => _SettingsSheetState();
}

class _SettingsSheetState extends State<_SettingsSheet> {
  late double _fs;
  bool _isApplying = false;

  @override
  void initState() {
    super.initState();
    _fs = widget.fontSize;
  }

  Future<void> _handleAction(VoidCallback action) async {
    if (_isApplying) return;
    setState(() => _isApplying = true);
    action();
    // Simulate application time (JS theme injection / layout sync)
    await Future.delayed(const Duration(milliseconds: 600));
    if (mounted) setState(() => _isApplying = false);
  }

  @override
  Widget build(BuildContext context) {
    final sheetBg = widget.isDark ? const Color(0xff1c1c1e) : Colors.white;
    final labelColor =
        widget.isDark ? Colors.white.withValues(alpha: 0.5) : const Color(0xff6D6875);
    final titleColor = widget.isDark ? Colors.white : const Color(0xff1a1a1a);
    final accentColor = Theme.of(context).colorScheme.primary;

    return Container(
      decoration: BoxDecoration(
        color: sheetBg,
        borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withValues(alpha: 0.2),
            blurRadius: 40,
            offset: const Offset(0, -10),
          ),
        ],
      ),
      padding: EdgeInsets.only(
        bottom: MediaQuery.of(context).viewInsets.bottom +
            MediaQuery.of(context).padding.bottom +
            24,
        top: 0,
        left: 28,
        right: 28,
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // Loader
          SizedBox(
            height: 3,
            child: _isApplying
                ? ClipRRect(
                    borderRadius: BorderRadius.circular(10),
                    child: LinearProgressIndicator(
                      backgroundColor: accentColor.withValues(alpha: 0.05),
                      color: accentColor,
                    ),
                  )
                : const SizedBox.shrink(),
          ),
          const SizedBox(height: 12),

          // Handle
          Center(
            child: Container(
              width: 40,
              height: 5,
              decoration: BoxDecoration(
                color: labelColor.withValues(alpha: 0.2),
                borderRadius: BorderRadius.circular(10),
              ),
            ),
          ),
          const SizedBox(height: 24),

          // Title
          Row(
            children: [
              Icon(IconlyLight.setting, color: accentColor, size: 22),
              const SizedBox(width: 12),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text(
                      'Appearance',
                      style: TextStyle(
                        color: titleColor,
                        fontSize: 20,
                        fontWeight: FontWeight.w800,
                        letterSpacing: -0.5,
                      ),
                    ),
                    if (_isApplying)
                      Padding(
                        padding: const EdgeInsets.only(top: 2),
                        child: Text(
                          'Applying changes...',
                          style: TextStyle(
                            color: accentColor,
                            fontSize: 11,
                            fontWeight: FontWeight.w600,
                          ),
                        ),
                      ),
                  ],
                ),
              ),
            ],
          ),
          const SizedBox(height: 28),

          // Theme label
          Text(
            'COLOR THEME',
            style: TextStyle(
              color: labelColor,
              fontSize: 10,
              fontWeight: FontWeight.w800,
              letterSpacing: 1.5,
            ),
          ),
          const SizedBox(height: 16),

          // Theme swatches
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Row(
              children: _themes.map((opt) {
                final selected = widget.themeType == opt.type;
                return GestureDetector(
                  onTap: () => _handleAction(() => widget.onTheme(opt.type)),
                  child: AnimatedOpacity(
                    duration: const Duration(milliseconds: 250),
                    opacity: _isApplying && !selected ? 0.3 : 1.0,
                    child: Container(
                      margin: const EdgeInsets.only(right: 16),
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          AnimatedContainer(
                            duration: const Duration(milliseconds: 250),
                            curve: Curves.easeOutCubic,
                            width: 52,
                            height: 52,
                            decoration: BoxDecoration(
                              color: opt.swatch,
                              shape: BoxShape.circle,
                              border: Border.all(
                                color: selected
                                    ? accentColor
                                    : labelColor.withValues(alpha: 0.1),
                                width: selected ? 3 : 1,
                              ),
                              boxShadow: selected
                                  ? [
                                      BoxShadow(
                                        color: accentColor.withValues(alpha: 0.2),
                                        blurRadius: 10,
                                        offset: const Offset(0, 4),
                                      )
                                    ]
                                  : null,
                            ),
                            child: selected
                                ? Icon(
                                    IconlyLight.tick_square,
                                    size: 22,
                                    color: opt.fg.withValues(alpha: 0.8),
                                  )
                                : null,
                          ),
                          const SizedBox(height: 8),
                          Text(
                            opt.label,
                            style: TextStyle(
                              color: selected ? titleColor : labelColor,
                              fontSize: 11,
                              fontWeight:
                                  selected ? FontWeight.w700 : FontWeight.w500,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ),
                );
              }).toList(),
            ),
          ),
          const SizedBox(height: 32),

          // Typography Section
          Row(
            children: [
              Text(
                'TYPOGRAPHY',
                style: TextStyle(
                  color: labelColor,
                  fontSize: 10,
                  fontWeight: FontWeight.w800,
                  letterSpacing: 1.5,
                ),
              ),
              const Spacer(),
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                decoration: BoxDecoration(
                  color: accentColor.withValues(alpha: 0.1),
                  borderRadius: BorderRadius.circular(6),
                ),
                child: Text(
                  widget.fontFamily,
                  style: TextStyle(
                    color: accentColor,
                    fontSize: 11,
                    fontWeight: FontWeight.w800,
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 16),

          // Font family picker
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Row(
              children: _fonts.map((f) {
                final selected = widget.fontFamily == f;
                return GestureDetector(
                  onTap: () => _handleAction(() => widget.onFont(f)),
                  child: AnimatedOpacity(
                    duration: const Duration(milliseconds: 200),
                    opacity: _isApplying && !selected ? 0.3 : 1.0,
                    child: AnimatedContainer(
                      duration: const Duration(milliseconds: 200),
                      margin: const EdgeInsets.only(right: 12),
                      padding: const EdgeInsets.symmetric(
                        horizontal: 16,
                        vertical: 12,
                      ),
                      decoration: BoxDecoration(
                        color: selected
                            ? accentColor
                            : labelColor.withValues(alpha: 0.05),
                        borderRadius: BorderRadius.circular(16),
                        border: Border.all(
                          color: selected
                              ? accentColor
                              : labelColor.withValues(alpha: 0.1),
                          width: 1,
                        ),
                        boxShadow: selected
                            ? [
                                BoxShadow(
                                  color: accentColor.withValues(alpha: 0.2),
                                  blurRadius: 10,
                                  offset: const Offset(0, 4),
                                )
                              ]
                            : null,
                      ),
                      child: Text(
                        f,
                        style: TextStyle(
                          fontFamily: f,
                          color: selected ? Colors.white : titleColor,
                          fontSize: 14,
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                    ),
                  ),
                );
              }).toList(),
            ),
          ),
          const SizedBox(height: 28),

          // Font size section
          Row(
            children: [
              Text(
                'SIZE',
                style: TextStyle(
                  color: labelColor,
                  fontSize: 10,
                  fontWeight: FontWeight.w800,
                  letterSpacing: 1.5,
                ),
              ),
              const Spacer(),
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                decoration: BoxDecoration(
                  color: accentColor.withValues(alpha: 0.1),
                  borderRadius: BorderRadius.circular(6),
                ),
                child: Text(
                  '${_fs.toInt()}px',
                  style: TextStyle(
                    color: accentColor,
                    fontSize: 12,
                    fontWeight: FontWeight.w800,
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 20),
          Row(
            children: [
              Icon(IconlyLight.document, size: 16, color: labelColor),
              Expanded(
                child: SliderTheme(
                  data: SliderTheme.of(context).copyWith(
                    activeTrackColor: accentColor,
                    inactiveTrackColor: accentColor.withValues(alpha: 0.1),
                    thumbColor: Colors.white,
                    overlayColor: accentColor.withValues(alpha: 0.12),
                    trackHeight: 4,
                    thumbShape: const RoundSliderThumbShape(
                      enabledThumbRadius: 10,
                      elevation: 4,
                      pressedElevation: 6,
                    ),
                  ),
                  child: Slider(
                    value: _fs,
                    min: 12,
                    max: 28,
                    divisions: 16,
                    onChanged: (v) {
                      setState(() => _fs = v);
                      widget.onFontSize(v);
                    },
                  ),
                ),
              ),
              Icon(IconlyLight.document, size: 24, color: labelColor),
            ],
          ),
        ],
      ),
    );
  }
}

// ── Chapter sheet ─────────────────────────────────────────────────────────────

class _ChapterSheet extends StatelessWidget {
  final List<EpubChapter> chapters;
  final bool isDark;
  final void Function(String href) onTap;

  const _ChapterSheet({
    required this.chapters,
    required this.isDark,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    final sheetBg = isDark ? const Color(0xff1c1c1e) : Colors.white;
    final titleColor = isDark ? Colors.white : const Color(0xff1a1a1a);
    final labelColor =
        isDark ? Colors.white.withValues(alpha: 0.5) : const Color(0xff6D6875);
    final divColor = isDark
        ? Colors.white.withValues(alpha: 0.04)
        : Colors.black.withValues(alpha: 0.04);
    final accentColor = Theme.of(context).colorScheme.primary;

    return DraggableScrollableSheet(
      initialChildSize: 0.6,
      minChildSize: 0.35,
      maxChildSize: 0.95,
      builder: (_, scrollCtrl) => Container(
        decoration: BoxDecoration(
          color: sheetBg,
          borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withValues(alpha: 0.2),
              blurRadius: 40,
              offset: const Offset(0, -10),
            ),
          ],
        ),
        child: Column(
          children: [
            const SizedBox(height: 12),
            Container(
              width: 40,
              height: 5,
              decoration: BoxDecoration(
                color: labelColor.withValues(alpha: 0.2),
                borderRadius: BorderRadius.circular(10),
              ),
            ),
            const SizedBox(height: 24),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 28),
              child: Row(
                children: [
                  Icon(IconlyLight.category, color: accentColor, size: 22),
                  const SizedBox(width: 12),
                  Text(
                    'Chapters',
                    style: TextStyle(
                      color: titleColor,
                      fontSize: 20,
                      fontWeight: FontWeight.w800,
                      letterSpacing: -0.5,
                    ),
                  ),
                  const Spacer(),
                  Container(
                    padding:
                        const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
                    decoration: BoxDecoration(
                      color: accentColor.withValues(alpha: 0.1),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    child: Text(
                      '${chapters.length}',
                      style: TextStyle(
                        color: accentColor,
                        fontSize: 12,
                        fontWeight: FontWeight.w800,
                      ),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 20),
            Divider(height: 1, color: divColor),
            Expanded(
              child: ListView.builder(
                controller: scrollCtrl,
                padding: EdgeInsets.only(
                  top: 8,
                  bottom: MediaQuery.of(context).padding.bottom + 24,
                ),
                itemCount: chapters.length,
                itemBuilder: (_, i) {
                  final ch = chapters[i];
                  return ListTile(
                    contentPadding: const EdgeInsets.symmetric(
                      horizontal: 28,
                      vertical: 4,
                    ),
                    title: Text(
                      ch.title,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                      style: TextStyle(
                        color: titleColor.withValues(alpha: 0.9),
                        fontSize: 15,
                        fontWeight: FontWeight.w600,
                      ),
                    ),
                    subtitle: Text(
                      'Chapter ${i + 1}',
                      style: TextStyle(
                        color: labelColor,
                        fontSize: 12,
                        fontWeight: FontWeight.w500,
                      ),
                    ),
                    trailing: Icon(
                      IconlyLight.arrow_right_2,
                      color: labelColor.withValues(alpha: 0.4),
                      size: 20,
                    ),
                    onTap: () => onTap(ch.href),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Search sheet ──────────────────────────────────────────────────────────────

class _SearchSheet extends StatefulWidget {
  final bool isDark;
  const _SearchSheet({required this.isDark});

  @override
  State<_SearchSheet> createState() => _SearchSheetState();
}

class _SearchSheetState extends State<_SearchSheet> {
  final _ctrl = TextEditingController();

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

  @override
  Widget build(BuildContext context) {
    final sheetBg = widget.isDark ? const Color(0xff1c1c1e) : Colors.white;
    final titleColor = widget.isDark ? Colors.white : const Color(0xff1a1a1a);
    final hintColor = widget.isDark ? Colors.white30 : Colors.black38;
    final fieldBg = widget.isDark
        ? Colors.white.withValues(alpha: 0.04)
        : Colors.black.withValues(alpha: 0.04);
    final accentColor = Theme.of(context).colorScheme.primary;

    return Padding(
      padding: EdgeInsets.only(
        bottom: MediaQuery.of(context).viewInsets.bottom,
      ),
      child: Container(
        decoration: BoxDecoration(
          color: sheetBg,
          borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withValues(alpha: 0.2),
              blurRadius: 40,
              offset: const Offset(0, -10),
            ),
          ],
        ),
        padding: const EdgeInsets.fromLTRB(28, 12, 28, 32),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Container(
              width: 40,
              height: 5,
              decoration: BoxDecoration(
                color: hintColor.withValues(alpha: 0.2),
                borderRadius: BorderRadius.circular(10),
              ),
            ),
            const SizedBox(height: 24),
            Row(
              children: [
                Icon(IconlyLight.search, color: accentColor, size: 22),
                const SizedBox(width: 12),
                Text(
                  'Search in Book',
                  style: TextStyle(
                    color: titleColor,
                    fontSize: 20,
                    fontWeight: FontWeight.w800,
                    letterSpacing: -0.5,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 24),
            Container(
              decoration: BoxDecoration(
                color: fieldBg,
                borderRadius: BorderRadius.circular(18),
                border: Border.all(
                  color: titleColor.withValues(alpha: 0.05),
                  width: 1,
                ),
              ),
              child: TextField(
                controller: _ctrl,
                autofocus: true,
                style: TextStyle(
                    color: titleColor,
                    fontSize: 16,
                    fontWeight: FontWeight.w500),
                decoration: InputDecoration(
                  hintText: 'Search for phrases, characters…',
                  hintStyle: TextStyle(
                      color: hintColor,
                      fontSize: 16,
                      fontWeight: FontWeight.w400),
                  prefixIcon: Icon(
                    IconlyLight.search,
                    color: titleColor.withValues(alpha: 0.2),
                    size: 20,
                  ),
                  border: InputBorder.none,
                  contentPadding: const EdgeInsets.symmetric(
                    horizontal: 20,
                    vertical: 18,
                  ),
                ),
                onSubmitted: (v) => Navigator.pop(context, v),
              ),
            ),
            const SizedBox(height: 20),
            SizedBox(
              width: double.infinity,
              child: _PillButton(
                label: 'Start Search',
                bgColor: accentColor,
                fgColor: Colors.white,
                onTap: () => Navigator.pop(context, _ctrl.text),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Search results sheet ──────────────────────────────────────────────────────

class _SearchResultsSheet extends StatelessWidget {
  final String query;
  final List<EpubSearchResult> results;
  final bool isDark;
  final void Function(String cfi) onTap;

  const _SearchResultsSheet({
    required this.query,
    required this.results,
    required this.isDark,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    final sheetBg = isDark ? const Color(0xff1c1c1e) : Colors.white;
    final titleColor = isDark ? Colors.white : const Color(0xff1a1a1a);
    final labelColor = isDark ? Colors.white60 : const Color(0xff888888);
    final divColor = isDark
        ? Colors.white.withValues(alpha: 0.04)
        : Colors.black.withValues(alpha: 0.04);
    final accentColor = Theme.of(context).colorScheme.primary;

    return DraggableScrollableSheet(
      initialChildSize: 0.6,
      minChildSize: 0.35,
      maxChildSize: 0.95,
      builder: (_, scrollCtrl) => Container(
        decoration: BoxDecoration(
          color: sheetBg,
          borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withValues(alpha: 0.2),
              blurRadius: 40,
              offset: const Offset(0, -10),
            ),
          ],
        ),
        child: Column(
          children: [
            const SizedBox(height: 12),
            Container(
              width: 40,
              height: 5,
              decoration: BoxDecoration(
                color: labelColor.withValues(alpha: 0.2),
                borderRadius: BorderRadius.circular(10),
              ),
            ),
            const SizedBox(height: 24),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 28),
              child: Row(
                children: [
                  Icon(IconlyLight.document, color: accentColor, size: 22),
                  const SizedBox(width: 12),
                  Expanded(
                    child: Text(
                      'Results for "$query"',
                      overflow: TextOverflow.ellipsis,
                      style: TextStyle(
                        color: titleColor,
                        fontSize: 20,
                        fontWeight: FontWeight.w800,
                        letterSpacing: -0.5,
                      ),
                    ),
                  ),
                  const SizedBox(width: 8),
                  Container(
                    padding:
                        const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
                    decoration: BoxDecoration(
                      color: accentColor.withValues(alpha: 0.1),
                      borderRadius: BorderRadius.circular(20),
                    ),
                    child: Text(
                      '${results.length}',
                      style: TextStyle(
                        color: accentColor,
                        fontSize: 12,
                        fontWeight: FontWeight.w800,
                      ),
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 20),
            Divider(height: 1, color: divColor),
            Expanded(
              child: results.isEmpty
                  ? Center(
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Icon(IconlyLight.info_square,
                              size: 48, color: labelColor.withValues(alpha: 0.3)),
                          const SizedBox(height: 16),
                          Text(
                            'No matches found',
                            style: TextStyle(
                                color: labelColor,
                                fontSize: 16,
                                fontWeight: FontWeight.w600),
                          ),
                        ],
                      ),
                    )
                  : ListView.separated(
                      controller: scrollCtrl,
                      padding: EdgeInsets.only(
                        top: 8,
                        bottom: MediaQuery.of(context).padding.bottom + 24,
                      ),
                      itemCount: results.length,
                      separatorBuilder: (_, __) =>
                          Divider(height: 1, color: divColor),
                      itemBuilder: (_, i) {
                        final res = results[i];
                        return ListTile(
                          contentPadding: const EdgeInsets.symmetric(
                            horizontal: 28,
                            vertical: 8,
                          ),
                          title: Text(
                            res.excerpt,
                            maxLines: 3,
                            overflow: TextOverflow.ellipsis,
                            style: TextStyle(
                              color: titleColor.withValues(alpha: 0.8),
                              fontSize: 14,
                              fontWeight: FontWeight.w500,
                              height: 1.4,
                            ),
                          ),
                          trailing: Icon(
                            IconlyLight.arrow_right_2,
                            color: labelColor.withValues(alpha: 0.4),
                            size: 18,
                          ),
                          onTap: () => onTap(res.cfi),
                        );
                      },
                    ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Small reusable widgets ────────────────────────────────────────────────────

class _BarButton extends StatefulWidget {
  final IconData icon;
  final Color color;
  final VoidCallback onTap;

  const _BarButton({
    required this.icon,
    required this.color,
    required this.onTap,
  });

  @override
  State<_BarButton> createState() => _BarButtonState();
}

class _BarButtonState extends State<_BarButton> {
  bool _pressed = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (_) => setState(() => _pressed = true),
      onTapUp: (_) => setState(() => _pressed = false),
      onTapCancel: () => setState(() => _pressed = false),
      onTap: widget.onTap,
      behavior: HitTestBehavior.opaque,
      child: AnimatedScale(
        scale: _pressed ? 0.92 : 1.0,
        duration: const Duration(milliseconds: 100),
        child: Container(
          padding: const EdgeInsets.all(12),
          decoration: BoxDecoration(
            color: Colors.transparent,
            borderRadius: BorderRadius.circular(12),
          ),
          child:
              Icon(widget.icon, color: widget.color.withValues(alpha: 0.9), size: 22),
        ),
      ),
    );
  }
}

class _NavButton extends StatefulWidget {
  final IconData icon;
  final String label;
  final Color fgColor;
  final VoidCallback onTap;
  final bool iconRight;

  const _NavButton({
    required this.icon,
    required this.label,
    required this.fgColor,
    required this.onTap,
    this.iconRight = false,
  });

  @override
  State<_NavButton> createState() => _NavButtonState();
}

class _NavButtonState extends State<_NavButton> {
  bool _pressed = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (_) => setState(() => _pressed = true),
      onTapUp: (_) => setState(() => _pressed = false),
      onTapCancel: () => setState(() => _pressed = false),
      onTap: widget.onTap,
      behavior: HitTestBehavior.opaque,
      child: AnimatedScale(
        scale: _pressed ? 0.95 : 1.0,
        duration: const Duration(milliseconds: 100),
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
          decoration: BoxDecoration(
            color: widget.fgColor.withValues(alpha: 0.06),
            borderRadius: BorderRadius.circular(20),
          ),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: widget.iconRight
                ? [
                    Text(
                      widget.label,
                      style: TextStyle(
                        color: widget.fgColor.withValues(alpha: 0.8),
                        fontSize: 14,
                        fontWeight: FontWeight.w600,
                        letterSpacing: -0.2,
                      ),
                    ),
                    const SizedBox(width: 4),
                    Icon(widget.icon,
                        color: widget.fgColor.withValues(alpha: 0.8), size: 18),
                  ]
                : [
                    Icon(widget.icon,
                        color: widget.fgColor.withValues(alpha: 0.8), size: 18),
                    const SizedBox(width: 4),
                    Text(
                      widget.label,
                      style: TextStyle(
                        color: widget.fgColor.withValues(alpha: 0.8),
                        fontSize: 14,
                        fontWeight: FontWeight.w600,
                        letterSpacing: -0.2,
                      ),
                    ),
                  ],
          ),
        ),
      ),
    );
  }
}

class _PillButton extends StatefulWidget {
  final String label;
  final Color bgColor;
  final Color fgColor;
  final VoidCallback onTap;

  const _PillButton({
    required this.label,
    required this.bgColor,
    required this.fgColor,
    required this.onTap,
  });

  @override
  State<_PillButton> createState() => _PillButtonState();
}

class _PillButtonState extends State<_PillButton> {
  bool _pressed = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (_) => setState(() => _pressed = true),
      onTapUp: (_) => setState(() => _pressed = false),
      onTapCancel: () => setState(() => _pressed = false),
      onTap: widget.onTap,
      child: AnimatedScale(
        scale: _pressed ? 0.94 : 1.0,
        duration: const Duration(milliseconds: 100),
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
          decoration: BoxDecoration(
            color: widget.bgColor,
            borderRadius: BorderRadius.circular(100),
            boxShadow: [
              BoxShadow(
                color: widget.bgColor.withValues(alpha: 0.3),
                blurRadius: 8,
                offset: const Offset(0, 4),
              ),
            ],
          ),
          child: Text(
            widget.label,
            style: TextStyle(
              color: widget.fgColor,
              fontSize: 12,
              fontWeight: FontWeight.w700,
            ),
          ),
        ),
      ),
    );
  }
}
1
likes
160
points
204
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A full-featured Flutter EPUB reader package. Render EPUB 2 & 3 books with annotations, search, themes, and programmatic navigation — powered by epub.js.

Repository (GitHub)
View/report issues

License

BSD-3-Clause (license)

Dependencies

flutter, flutter_inappwebview, http, json_annotation

More

Packages that depend on sakura_epub