turnable_page 1.1.0 copy "turnable_page: ^1.1.0" to clipboard
turnable_page: ^1.1.0 copied to clipboard

Realistic page-flipping widget for Flutter with smooth animations, interactive content, and smart gesture detection.

example/lib/main.dart

import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:turnable_page/turnable_page.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await TurnablePdf.initPDFLoaders();
  runApp(const TurnableTestApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Turnable Page — Test Suite',
      debugShowCheckedModeBanner: false,
      scrollBehavior: const MaterialScrollBehavior().copyWith(
        dragDevices: {
          PointerDeviceKind.mouse,
          PointerDeviceKind.touch,
          PointerDeviceKind.stylus,
          PointerDeviceKind.trackpad,
        },
      ),
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF3D5AFE),
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
        fontFamily: 'Roboto',
      ),
      home: const HomeScreen(),
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// HOME SCREEN — selector between Widget Book and PDF Book
// ─────────────────────────────────────────────────────────────────────────────

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0D1117),
      body: SafeArea(
        child: Column(
          children: [
            const SizedBox(height: 40),
            // ── Header ──────────────────────────────────────────────────────
            const Icon(Icons.auto_stories, size: 72, color: Color(0xFF3D5AFE)),
            const SizedBox(height: 16),
            const Text(
              'Turnable Page',
              style: TextStyle(
                fontSize: 32,
                fontWeight: FontWeight.bold,
                color: Colors.white,
                letterSpacing: 1.5,
              ),
            ),
            const SizedBox(height: 8),
            Text(
              'Integration Test Suite',
              style: TextStyle(
                fontSize: 15,
                color: Colors.white.withValues(alpha: 0.5),
                letterSpacing: 1.2,
              ),
            ),
            const SizedBox(height: 60),

            // ── Mode Cards ──────────────────────────────────────────────────
            Expanded(
              child: Padding(
                padding: const EdgeInsets.symmetric(horizontal: 24),
                child: Column(
                  children: [
                    _ModeCard(
                      icon: Icons.widgets_rounded,
                      title: 'Widget Book',
                      subtitle:
                          'Test page-flip with Flutter widgets\n'
                          '(text, buttons, images, scrollable content)',
                      accentColor: const Color(0xFF00E5FF),
                      onTap: () => Navigator.push(
                        context,
                        MaterialPageRoute(
                          builder: (_) => const WidgetBookScreen(),
                        ),
                      ),
                    ),
                    const SizedBox(height: 20),
                    _ModeCard(
                      icon: Icons.picture_as_pdf_rounded,
                      title: 'PDF Book',
                      subtitle:
                          'Test page-flip with a real PDF document\n'
                          '(asset PDF or network URL)',
                      accentColor: const Color(0xFFFF6D00),
                      onTap: () => Navigator.push(
                        context,
                        MaterialPageRoute(
                          builder: (_) => const PdfPickerScreen(),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),

            // ── Version badge ────────────────────────────────────────────────
            Padding(
              padding: const EdgeInsets.only(bottom: 24),
              child: Text(
                'turnable_page v1.0.2',
                style: TextStyle(
                  fontSize: 12,
                  color: Colors.white.withValues(alpha: 0.3),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _ModeCard extends StatelessWidget {
  final IconData icon;
  final String title;
  final String subtitle;
  final Color accentColor;
  final VoidCallback onTap;

  const _ModeCard({
    required this.icon,
    required this.title,
    required this.subtitle,
    required this.accentColor,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        width: double.infinity,
        padding: const EdgeInsets.all(24),
        decoration: BoxDecoration(
          color: const Color(0xFF161B22),
          borderRadius: BorderRadius.circular(20),
          border: Border.all(
            color: accentColor.withValues(alpha: 0.3),
            width: 1.5,
          ),
          boxShadow: [
            BoxShadow(
              color: accentColor.withValues(alpha: 0.08),
              blurRadius: 20,
              spreadRadius: 2,
            ),
          ],
        ),
        child: Row(
          children: [
            Container(
              width: 60,
              height: 60,
              decoration: BoxDecoration(
                color: accentColor.withValues(alpha: 0.12),
                borderRadius: BorderRadius.circular(16),
              ),
              child: Icon(icon, color: accentColor, size: 30),
            ),
            const SizedBox(width: 20),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    title,
                    style: const TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                      color: Colors.white,
                    ),
                  ),
                  const SizedBox(height: 6),
                  Text(
                    subtitle,
                    style: TextStyle(
                      fontSize: 13,
                      color: Colors.white.withValues(alpha: 0.5),
                      height: 1.5,
                    ),
                  ),
                ],
              ),
            ),
            Icon(
              Icons.arrow_forward_ios_rounded,
              color: accentColor.withValues(alpha: 0.6),
              size: 18,
            ),
          ],
        ),
      ),
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// WIDGET BOOK SCREEN — tests Issue #5 (CachedImage-like), #4 (Stack+Scroll)
// ─────────────────────────────────────────────────────────────────────────────

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

  @override
  State<WidgetBookScreen> createState() => _WidgetBookScreenState();
}

class _WidgetBookScreenState extends State<WidgetBookScreen> {
  final PageFlipController _controller = PageFlipController();
  final ValueNotifier<int> _currentPage = ValueNotifier(0);

  // Different page "types" to stress-test compositing scenarios
  static const List<_PageConfig> _pages = [
    _PageConfig(
      label: 'Page 1',
      type: _PageType.gradient,
      color: Color(0xFF3D5AFE),
      description: 'Basic gradient page\n(baseline render test)',
    ),
    _PageConfig(
      label: 'Page 2',
      type: _PageType.networkImage,
      color: Color(0xFF00BFA5),
      description:
          'Network image via Image.network\n(Issue #9: image rendering)',
    ),
    _PageConfig(
      label: 'Page 3',
      type: _PageType.scrollable,
      color: Color(0xFFFF6D00),
      description:
          'Stack + SingleChildScrollView\n(Issue #4: compositing crash fix)',
    ),
    _PageConfig(
      label: 'Page 4',
      type: _PageType.repaintBoundary,
      color: Color(0xFFAA00FF),
      description: 'RepaintBoundary child\n(Issue #5: native peer crash fix)',
    ),
    _PageConfig(
      label: 'Page 5',
      type: _PageType.interactive,
      color: Color(0xFFD50000),
      description: 'Interactive buttons & gestures\n(tap + long-press test)',
    ),
    _PageConfig(
      label: 'Page 6',
      type: _PageType.gradient,
      color: Color(0xFF00C853),
      description: 'Final page',
    ),
  ];

  TextDirection? _direction;

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0D1117),
      appBar: AppBar(
        backgroundColor: const Color(0xFF161B22),
        foregroundColor: Colors.white,
        title: ValueListenableBuilder<int>(
          valueListenable: _currentPage,
          builder: (_, page, __) => Text(
            'Widget Book — Page ${page + 1} / ${_pages.length}',
            style: const TextStyle(fontSize: 16),
          ),
        ),
        actions: [
          PopupMenuButton<TextDirection?>(
            initialValue: _direction,
            tooltip: 'Reading Direction',
            icon: Icon(
              _direction == TextDirection.rtl
                  ? Icons.format_textdirection_r_to_l_rounded
                  : _direction == TextDirection.ltr
                  ? Icons.format_textdirection_l_to_r_rounded
                  : Icons.auto_mode_rounded,
              color: const Color(0xFF00E5FF),
            ),
            onSelected: (dir) => setState(() => _direction = dir),
            itemBuilder: (_) => const [
              PopupMenuItem(
                value: null,
                child: Text('Auto (System / Directionality)'),
              ),
              PopupMenuItem(
                value: TextDirection.ltr,
                child: Text('LTR (English / Left-to-Right)'),
              ),
              PopupMenuItem(
                value: TextDirection.rtl,
                child: Text('RTL (العربية / Right-to-Left)'),
              ),
            ],
          ),
          const SizedBox(width: 8),
        ],
      ),
      body: Center(
        child: TurnablePage(
          controller: _controller,
          pageCount: _pages.length,
          pageViewMode: PageViewMode.auto,
          paperBoundaryDecoration: PaperBoundaryDecoration.modern,
          textDirection: _direction,
          settings: FlipSettings(
            drawShadow: true,
            hideLeftShadow: true,
            flippingTime: 700,
            swipeDistance: 60,
            cornerTriggerAreaSize: 0.2,
            enableEasing: true,
            enableInertia: true,
          ),
          onPageChanged: (left, right) {
            _currentPage.value = left;
          },
          builder: (context, pageIndex, constraints) =>
              _PageContent(config: _pages[pageIndex]),
        ),
      ),
      bottomNavigationBar: _BottomNav(controller: _controller, pages: _pages),
    );
  }
}

enum _PageType {
  gradient,
  networkImage,
  scrollable,
  repaintBoundary,
  interactive,
}

class _PageConfig {
  final String label;
  final _PageType type;
  final Color color;
  final String description;

  const _PageConfig({
    required this.label,
    required this.type,
    required this.color,
    required this.description,
  });
}

class _PageContent extends StatelessWidget {
  final _PageConfig config;

  const _PageContent({required this.config});

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: switch (config.type) {
        _PageType.gradient => _GradientPage(config: config),
        _PageType.networkImage => _NetworkImagePage(config: config),
        _PageType.scrollable => _ScrollablePage(config: config),
        _PageType.repaintBoundary => _RepaintBoundaryPage(config: config),
        _PageType.interactive => _InteractivePage(config: config),
      },
    );
  }
}

// ── 1. Simple gradient ────────────────────────────────────────────────────────
class _GradientPage extends StatelessWidget {
  final _PageConfig config;

  const _GradientPage({required this.config});

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: [config.color.withValues(alpha: 0.7), config.color],
        ),
      ),
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.auto_stories, size: 80, color: Colors.white),
            const SizedBox(height: 20),
            Text(
              config.label,
              style: const TextStyle(
                fontSize: 36,
                fontWeight: FontWeight.bold,
                color: Colors.white,
              ),
            ),
            const SizedBox(height: 12),
            Container(
              margin: const EdgeInsets.symmetric(horizontal: 32),
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
              decoration: BoxDecoration(
                color: Colors.black.withValues(alpha: 0.25),
                borderRadius: BorderRadius.circular(12),
              ),
              child: Text(
                config.description,
                textAlign: TextAlign.center,
                style: const TextStyle(
                  fontSize: 14,
                  color: Colors.white,
                  height: 1.5,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── 2. Network image (Issue #9 test) ─────────────────────────────────────────
class _NetworkImagePage extends StatelessWidget {
  final _PageConfig config;

  const _NetworkImagePage({required this.config});

  @override
  Widget build(BuildContext context) {
    return Stack(
      fit: StackFit.expand,
      children: [
        Image.network(
          'https://picsum.photos/seed/turnable/600/900',
          fit: BoxFit.cover,
          loadingBuilder: (_, child, event) {
            if (event == null) return child;
            return Container(
              color: config.color.withValues(alpha: 0.15),
              child: Center(
                child: CircularProgressIndicator(color: config.color),
              ),
            );
          },
          errorBuilder: (_, __, ___) => Container(
            color: config.color.withValues(alpha: 0.2),
            child: const Center(
              child: Icon(Icons.broken_image, size: 60, color: Colors.white54),
            ),
          ),
        ),
        // Semi-transparent overlay with label
        Positioned(
          bottom: 0,
          left: 0,
          right: 0,
          child: Container(
            padding: const EdgeInsets.all(20),
            decoration: BoxDecoration(
              gradient: LinearGradient(
                begin: Alignment.topCenter,
                end: Alignment.bottomCenter,
                colors: [
                  Colors.transparent,
                  Colors.black.withValues(alpha: 0.8),
                ],
              ),
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  config.label,
                  style: const TextStyle(
                    fontSize: 24,
                    fontWeight: FontWeight.bold,
                    color: Colors.white,
                  ),
                ),
                Text(
                  config.description,
                  style: const TextStyle(fontSize: 13, color: Colors.white70),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

// ── 3. Scrollable (Issue #4 test) ────────────────────────────────────────────
class _ScrollablePage extends StatelessWidget {
  final _PageConfig config;

  const _ScrollablePage({required this.config});

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        // Background color
        Container(color: config.color.withValues(alpha: 0.05)),

        // Scrollable content area (Issue #4 scenario: Stack + SingleChildScrollView)
        SingleChildScrollView(
          padding: const EdgeInsets.all(20),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // Header
              Container(
                padding: const EdgeInsets.all(16),
                decoration: BoxDecoration(
                  color: config.color,
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Row(
                  children: [
                    const Icon(Icons.bug_report, color: Colors.white, size: 24),
                    const SizedBox(width: 12),
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(
                            config.label,
                            style: const TextStyle(
                              fontSize: 18,
                              fontWeight: FontWeight.bold,
                              color: Colors.white,
                            ),
                          ),
                          Text(
                            'Issue #4 — Stack + ScrollView test',
                            style: TextStyle(
                              fontSize: 12,
                              color: Colors.white.withValues(alpha: 0.8),
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 16),

              // Long scrollable content
              for (int i = 1; i <= 12; i++)
                _ContentRow(
                  index: i,
                  color: config.color,
                  text:
                      'Scrollable item #$i — This content verifies that '
                      'Stack + SingleChildScrollView renders correctly inside a page-flip '
                      'without layout collapse or compositing crash.',
                ),
            ],
          ),
        ),

        // Floating badge (Stack overlay = also testing Stack compositing)
        Positioned(
          top: 12,
          right: 12,
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
            decoration: BoxDecoration(
              color: config.color,
              borderRadius: BorderRadius.circular(20),
            ),
            child: const Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(Icons.check_circle, color: Colors.white, size: 14),
                SizedBox(width: 4),
                Text(
                  'Compositing OK',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 11,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

class _ContentRow extends StatelessWidget {
  final int index;
  final Color color;
  final String text;

  const _ContentRow({
    required this.index,
    required this.color,
    required this.text,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            width: 28,
            height: 28,
            decoration: BoxDecoration(
              color: color.withValues(alpha: 0.15),
              borderRadius: BorderRadius.circular(8),
            ),
            alignment: Alignment.center,
            child: Text(
              '$index',
              style: TextStyle(
                fontSize: 12,
                fontWeight: FontWeight.bold,
                color: color,
              ),
            ),
          ),
          const SizedBox(width: 10),
          Expanded(
            child: Text(
              text,
              style: const TextStyle(
                fontSize: 13,
                color: Colors.black87,
                height: 1.5,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// ── 4. RepaintBoundary (Issue #5 stress test) ─────────────────────────────────
class _RepaintBoundaryPage extends StatefulWidget {
  final _PageConfig config;

  const _RepaintBoundaryPage({required this.config});

  @override
  State<_RepaintBoundaryPage> createState() => _RepaintBoundaryPageState();
}

class _RepaintBoundaryPageState extends State<_RepaintBoundaryPage>
    with SingleTickerProviderStateMixin {
  late final AnimationController _anim;
  int _counter = 0;

  @override
  void initState() {
    super.initState();
    _anim = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    )..repeat();
  }

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: widget.config.color.withValues(alpha: 0.05),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          // RepaintBoundary wrapping an animated widget = Issue #5 scenario
          RepaintBoundary(
            child: AnimatedBuilder(
              animation: _anim,
              builder: (_, __) => Container(
                width: 140,
                height: 140,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  gradient: SweepGradient(
                    colors: [
                      widget.config.color,
                      widget.config.color.withValues(alpha: 0.1),
                      widget.config.color,
                    ],
                    transform: GradientRotation(_anim.value * 6.28),
                  ),
                ),
                child: const Icon(Icons.sync, color: Colors.white, size: 48),
              ),
            ),
          ),
          const SizedBox(height: 24),
          Text(
            widget.config.label,
            style: TextStyle(
              fontSize: 26,
              fontWeight: FontWeight.bold,
              color: widget.config.color,
            ),
          ),
          const SizedBox(height: 8),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 32),
            child: Text(
              widget.config.description,
              textAlign: TextAlign.center,
              style: const TextStyle(
                fontSize: 13,
                color: Colors.black54,
                height: 1.5,
              ),
            ),
          ),
          const SizedBox(height: 24),
          // Button that triggers setState (forces repaint while page may be animating)
          ElevatedButton.icon(
            onPressed: () => setState(() => _counter++),
            icon: const Icon(Icons.add),
            label: Text('Repaint #$_counter'),
            style: ElevatedButton.styleFrom(
              backgroundColor: widget.config.color,
              foregroundColor: Colors.white,
            ),
          ),
          const SizedBox(height: 8),
          Text(
            'RepaintBoundary does NOT crash anymore ✓',
            style: TextStyle(
              fontSize: 12,
              color: Colors.green[700],
              fontWeight: FontWeight.bold,
            ),
          ),
        ],
      ),
    );
  }
}

// ── 5. Interactive buttons ────────────────────────────────────────────────────
class _InteractivePage extends StatefulWidget {
  final _PageConfig config;

  const _InteractivePage({required this.config});

  @override
  State<_InteractivePage> createState() => _InteractivePageState();
}

class _InteractivePageState extends State<_InteractivePage> {
  String _lastEvent = 'None';

  @override
  Widget build(BuildContext context) {
    return Container(
      color: widget.config.color.withValues(alpha: 0.05),
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(
              Icons.touch_app_rounded,
              size: 70,
              color: Colors.black26,
            ),
            const SizedBox(height: 16),
            Text(
              widget.config.label,
              style: TextStyle(
                fontSize: 28,
                fontWeight: FontWeight.bold,
                color: widget.config.color,
              ),
            ),
            const SizedBox(height: 8),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 32),
              child: Text(
                widget.config.description,
                textAlign: TextAlign.center,
                style: const TextStyle(fontSize: 13, color: Colors.black54),
              ),
            ),
            const SizedBox(height: 32),

            // Tap test
            ElevatedButton.icon(
              onPressed: () {
                setState(() => _lastEvent = 'TAP on button ✓');
                HapticFeedback.lightImpact();
              },
              icon: const Icon(Icons.ads_click),
              label: const Text('Tap Me'),
              style: ElevatedButton.styleFrom(
                backgroundColor: widget.config.color,
                foregroundColor: Colors.white,
                minimumSize: const Size(200, 50),
              ),
            ),
            const SizedBox(height: 12),

            // Long-press test
            GestureDetector(
              onLongPress: () {
                setState(() => _lastEvent = 'LONG PRESS detected ✓');
                HapticFeedback.mediumImpact();
              },
              child: Container(
                width: 200,
                height: 50,
                decoration: BoxDecoration(
                  border: Border.all(color: widget.config.color, width: 2),
                  borderRadius: BorderRadius.circular(12),
                ),
                alignment: Alignment.center,
                child: Text(
                  'Long Press Me',
                  style: TextStyle(
                    color: widget.config.color,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
            const SizedBox(height: 24),

            // Last event display
            AnimatedContainer(
              duration: const Duration(milliseconds: 300),
              padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
              decoration: BoxDecoration(
                color: _lastEvent == 'None'
                    ? Colors.grey.withValues(alpha: 0.1)
                    : Colors.green.withValues(alpha: 0.15),
                borderRadius: BorderRadius.circular(12),
                border: Border.all(
                  color: _lastEvent == 'None'
                      ? Colors.grey.withValues(alpha: 0.3)
                      : Colors.green.withValues(alpha: 0.5),
                ),
              ),
              child: Text(
                'Last event: $_lastEvent',
                style: TextStyle(
                  fontSize: 14,
                  color: _lastEvent == 'None'
                      ? Colors.black38
                      : Colors.green[700],
                  fontWeight: FontWeight.w500,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Bottom Navigation ─────────────────────────────────────────────────────────
class _BottomNav extends StatelessWidget {
  final PageFlipController controller;
  final List<_PageConfig> pages;

  const _BottomNav({required this.controller, required this.pages});

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 80,
      color: const Color(0xFF161B22),
      padding: const EdgeInsets.symmetric(horizontal: 24),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          _NavBtn(
            icon: Icons.arrow_back_ios_new_rounded,
            label: 'Previous',
            onTap: () async => await controller.previousPage(),
            color: const Color(0xFF00E5FF),
          ),
          _NavBtn(
            icon: Icons.first_page_rounded,
            label: 'First',
            onTap: () async => await controller.animateToFirstPage(),
            color: Colors.white38,
          ),
          _NavBtn(
            icon: Icons.last_page_rounded,
            label: 'Last',
            onTap: () async => await controller.animateToLastPage(),
            color: Colors.white38,
          ),
          _NavBtn(
            icon: Icons.arrow_forward_ios_rounded,
            label: 'Next',
            onTap: () async => await controller.nextPage(),
            color: const Color(0xFF00E5FF),
          ),
        ],
      ),
    );
  }
}

class _NavBtn extends StatelessWidget {
  final IconData icon;
  final String label;
  final VoidCallback onTap;
  final Color color;

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(icon, color: color, size: 22),
          const SizedBox(height: 4),
          Text(
            label,
            style: TextStyle(fontSize: 11, color: color.withValues(alpha: 0.8)),
          ),
        ],
      ),
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// PDF PICKER SCREEN — choose between asset PDF or network URL
// ─────────────────────────────────────────────────────────────────────────────

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF0D1117),
      appBar: AppBar(
        backgroundColor: const Color(0xFF161B22),
        foregroundColor: Colors.white,
        title: const Text('PDF Book — Source Selection'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(24),
        children: [
          const SizedBox(height: 8),
          const Text(
            'Choose a PDF source:',
            style: TextStyle(
              fontSize: 18,
              fontWeight: FontWeight.bold,
              color: Colors.white,
            ),
          ),
          const SizedBox(height: 24),

          // Asset PDF options
          _SectionLabel(label: 'Asset PDFs', icon: Icons.folder_open_rounded),
          const SizedBox(height: 12),
          _PdfSourceCard(
            icon: Icons.description_rounded,
            title: 'CV.pdf (Asset)',
            subtitle: 'Small asset PDF — fast loading',
            accentColor: const Color(0xFFFF6D00),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(
                builder: (_) => const PdfBookScreen(
                  source: _PdfSource.asset,
                  assetPath: 'assets/CV.pdf',
                  title: 'CV — Asset PDF',
                ),
              ),
            ),
          ),
          const SizedBox(height: 12),
          _PdfSourceCard(
            icon: Icons.school_rounded,
            title: 'DL Lecture PDF (Asset)',
            subtitle: 'Large asset PDF — tests memory handling',
            accentColor: const Color(0xFFFF6D00),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(
                builder: (_) => const PdfBookScreen(
                  source: _PdfSource.asset,
                  assetPath: 'assets/DL-CNN-3-Padding-lec11.pdf',
                  title: 'DL Lecture — Large Asset PDF',
                ),
              ),
            ),
          ),

          const SizedBox(height: 32),
          _SectionLabel(
            label: 'Network PDFs',
            icon: Icons.cloud_download_rounded,
          ),
          const SizedBox(height: 12),
          _PdfSourceCard(
            icon: Icons.public_rounded,
            title: 'Apryse Demo PDF (Network)',
            subtitle: 'Real-world network PDF test',
            accentColor: const Color(0xFF3D5AFE),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(
                builder: (_) => const PdfBookScreen(
                  source: _PdfSource.network,
                  url:
                      'https://showcase.apryse.com/gallery/WebviewerDemoDoc.pdf',
                  title: 'Apryse Demo — Network PDF',
                ),
              ),
            ),
          ),
          const SizedBox(height: 12),
          _PdfSourceCard(
            icon: Icons.menu_book_rounded,
            title: 'Flutter Docs PDF (Network)',
            subtitle: 'Tests large network PDF with many pages',
            accentColor: const Color(0xFF3D5AFE),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(
                builder: (_) => const PdfBookScreen(
                  source: _PdfSource.network,
                  url:
                      'https://www.w3.org/WAI/WCAG21/Techniques/pdf/pdf-techniques.pdf',
                  title: 'WCAG PDF — Large Network PDF',
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _SectionLabel extends StatelessWidget {
  final String label;
  final IconData icon;

  const _SectionLabel({required this.label, required this.icon});

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Icon(icon, color: Colors.white38, size: 18),
        const SizedBox(width: 8),
        Text(
          label,
          style: const TextStyle(
            fontSize: 13,
            fontWeight: FontWeight.w600,
            color: Colors.white38,
            letterSpacing: 1,
          ),
        ),
        const SizedBox(width: 8),
        Expanded(child: Container(height: 1, color: Colors.white12)),
      ],
    );
  }
}

class _PdfSourceCard extends StatelessWidget {
  final IconData icon;
  final String title;
  final String subtitle;
  final Color accentColor;
  final VoidCallback onTap;

  const _PdfSourceCard({
    required this.icon,
    required this.title,
    required this.subtitle,
    required this.accentColor,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: const Color(0xFF161B22),
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: accentColor.withValues(alpha: 0.2)),
        ),
        child: Row(
          children: [
            Container(
              width: 48,
              height: 48,
              decoration: BoxDecoration(
                color: accentColor.withValues(alpha: 0.12),
                borderRadius: BorderRadius.circular(12),
              ),
              child: Icon(icon, color: accentColor, size: 24),
            ),
            const SizedBox(width: 16),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    title,
                    style: const TextStyle(
                      fontSize: 15,
                      fontWeight: FontWeight.bold,
                      color: Colors.white,
                    ),
                  ),
                  const SizedBox(height: 3),
                  Text(
                    subtitle,
                    style: const TextStyle(fontSize: 12, color: Colors.white38),
                  ),
                ],
              ),
            ),
            Icon(
              Icons.open_in_new_rounded,
              color: accentColor.withValues(alpha: 0.6),
              size: 16,
            ),
          ],
        ),
      ),
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// PDF BOOK SCREEN — actual TurnablePdf viewer
// ─────────────────────────────────────────────────────────────────────────────

enum _PdfSource { asset, network }

class PdfBookScreen extends StatefulWidget {
  final _PdfSource source;
  final String? assetPath;
  final String? url;
  final String title;

  const PdfBookScreen({
    super.key,
    required this.source,
    required this.title,
    this.assetPath,
    this.url,
  });

  @override
  State<PdfBookScreen> createState() => _PdfBookScreenState();
}

class _PdfBookScreenState extends State<PdfBookScreen> {
  final PageFlipController _controller = PageFlipController();
  final ValueNotifier<int> _currentPage = ValueNotifier(0);
  bool _isTwoPageMode = true;
  TextDirection? _direction;

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

  Widget _buildPdfWidget() {
    final pageViewMode = _isTwoPageMode
        ? PageViewMode.double
        : PageViewMode.single;

    final settings = FlipSettings(
      drawShadow: true,
      hideLeftShadow: true,
      flippingTime: 700,
      swipeDistance: 60,
      cornerTriggerAreaSize: 0.2,
      pageViewMode: pageViewMode,
    );

    if (widget.source == _PdfSource.asset) {
      return TurnablePdf.asset(
        widget.assetPath!,
        controller: _controller,
        pageViewMode: pageViewMode,
        paperBoundaryDecoration: PaperBoundaryDecoration.modern,
        settings: settings,
        textDirection: _direction,
        onPageChanged: (left, right) => _currentPage.value = left,
      );
    } else {
      return TurnablePdf.network(
        widget.url!,
        controller: _controller,
        pageViewMode: pageViewMode,
        paperBoundaryDecoration: PaperBoundaryDecoration.modern,
        settings: settings,
        textDirection: _direction,
        onPageChanged: (left, right) => _currentPage.value = left,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF1A0F0A),
      appBar: AppBar(
        backgroundColor: const Color(0xFF2D1B0E),
        foregroundColor: Colors.white,
        title: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(widget.title, style: const TextStyle(fontSize: 14)),
            ValueListenableBuilder<int>(
              valueListenable: _currentPage,
              builder: (_, page, __) => Text(
                'Page ${page + 1}',
                style: const TextStyle(fontSize: 12, color: Colors.white54),
              ),
            ),
          ],
        ),
        actions: [
          // Direction selector (Auto, LTR, RTL)
          PopupMenuButton<TextDirection?>(
            initialValue: _direction,
            tooltip: 'Reading Direction',
            icon: Icon(
              _direction == TextDirection.rtl
                  ? Icons.format_textdirection_r_to_l_rounded
                  : _direction == TextDirection.ltr
                  ? Icons.format_textdirection_l_to_r_rounded
                  : Icons.auto_mode_rounded,
              color: const Color(0xFFFFAB40),
            ),
            onSelected: (dir) => setState(() => _direction = dir),
            itemBuilder: (_) => const [
              PopupMenuItem(
                value: null,
                child: Text('Auto (System / Directionality)'),
              ),
              PopupMenuItem(
                value: TextDirection.ltr,
                child: Text('LTR (English / Left-to-Right)'),
              ),
              PopupMenuItem(
                value: TextDirection.rtl,
                child: Text('RTL (العربية / Right-to-Left)'),
              ),
            ],
          ),
          // Toggle single / two-page mode
          Tooltip(
            message: _isTwoPageMode
                ? 'Switch to single page'
                : 'Switch to two-page spread',
            child: IconButton(
              icon: Icon(
                _isTwoPageMode
                    ? Icons.chrome_reader_mode_outlined
                    : Icons.menu_book_outlined,
                color: const Color(0xFFFFAB40),
              ),
              onPressed: () => setState(() => _isTwoPageMode = !_isTwoPageMode),
            ),
          ),
          const SizedBox(width: 8),
        ],
      ),
      body: Container(
        decoration: const BoxDecoration(
          gradient: RadialGradient(
            center: Alignment.center,
            radius: 1.2,
            colors: [Color(0xFF3E1F00), Color(0xFF1A0F0A)],
          ),
        ),
        child: _buildPdfWidget(),
      ),
      bottomNavigationBar: Container(
        height: 72,
        color: const Color(0xFF2D1B0E),
        padding: const EdgeInsets.symmetric(horizontal: 24),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            _NavBtn(
              icon: Icons.arrow_back_ios_new_rounded,
              label: 'Previous',
              onTap: () => _controller.previousPage(),
              color: const Color(0xFFFFAB40),
            ),
            _NavBtn(
              icon: Icons.first_page_rounded,
              label: 'First',
              onTap: () => _controller.animateToFirstPage(),
              color: Colors.white38,
            ),
            _NavBtn(
              icon: Icons.last_page_rounded,
              label: 'Last',
              onTap: () => _controller.animateToLastPage(),
              color: Colors.white38,
            ),
            _NavBtn(
              icon: Icons.arrow_forward_ios_rounded,
              label: 'Next',
              onTap: () => _controller.nextPage(),
              color: const Color(0xFFFFAB40),
            ),
          ],
        ),
      ),
    );
  }
}
45
likes
135
points
485
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Realistic page-flipping widget for Flutter with smooth animations, interactive content, and smart gesture detection.

Homepage
Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, pdfrx

More

Packages that depend on turnable_page