book_page_flip 0.1.0 copy "book_page_flip: ^0.1.0" to clipboard
book_page_flip: ^0.1.0 copied to clipboard

A smooth, efficient open-book page-turn widget. Drag or animate pages with a realistic 3D page curl, a soft shadow, and a natural-feeling turn.

example/lib/main.dart

// Visual stress / QA lab for the book_page_flip package.
//
// Launch it and exercise every dimension that could produce a visual artifact —
// content type, page count, fit, paper dials, curl trajectory, each effect, pixel
// ratio, the atlas TEXTURE CAP (maxTextureDimension), the MESH resolution
// (meshResolution — reopens the book at the same spread via a fresh controller
// seeded with initialSpread), page labels — while watching the live book. The
// "Grid" content makes any mesh distortion, seam or mirroring obvious; "Auto-flip"
// stress-tests continuous turning and mid-flip interrupts; a page scrubber drives
// goToPage; the diagnostics bar reports the live position and turn progress.
//
//   flutter run -d macos      (native desktop window)
//   flutter run -d chrome     (browser)
//
// Structure: a single immutable [_Config] is the source of truth; the control
// panel emits edited configs and the book reads them. There are no widget-returning
// helper methods — every piece of UI is its own widget. The page builder is
// memoised ([_pageBuilder]) and only re-created when the CONTENT changes, so paper,
// curl, effect and fit changes reconfigure the book live without re-capturing it.
import 'dart:async';

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

void main() => runApp(const StressLabApp());

/// Root of the stress lab.
class StressLabApp extends StatelessWidget {
  /// Creates the app.
  const StressLabApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'book_page_flip stress lab',
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF6C4AB6),
          brightness: Brightness.dark,
        ),
      ),
      home: const _StressLab(),
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
//  Screen — owns the controller, the live config, and the auto-flip driver.
// ─────────────────────────────────────────────────────────────────────────────

class _StressLab extends StatefulWidget {
  const _StressLab();

  @override
  State<_StressLab> createState() => _StressLabState();
}

class _StressLabState extends State<_StressLab> {
  BookFlipController _controller = BookFlipController();
  _Config _config = const _Config();

  // Memoised page builder: a stable reference so paper/curl/effect/fit edits
  // reconfigure the book WITHOUT a re-capture. Re-created only when the content
  // mode changes (which must re-capture), in [_applyConfig].
  late Widget Function(BuildContext, int) _pageBuilder;

  Timer? _autoTimer;
  int _autoDir = 1;
  bool _autoFlip = false;

  @override
  void initState() {
    super.initState();
    _rebuildPageBuilder(_config.content);
  }

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

  void _rebuildPageBuilder(_ContentMode mode) {
    _pageBuilder = (context, index) => _StressPage(mode: mode, index: index);
  }

  void _applyConfig(_Config next) {
    // Content and the atlas texture cap are baked in at capture/boot time, so a
    // change to either re-creates the page builder to force a fresh capture.
    if (next.content != _config.content ||
        next.maxTextureDimension != _config.maxTextureDimension) {
      _rebuildPageBuilder(next.content);
    }
    setState(() => _config = next);
  }

  // Mesh tessellation is read once, at the book's initState, so changing it
  // reopens the book: a fresh controller seeded at the current spread
  // (initialSpread) re-attaches to the State the new ValueKey forces to rebuild.
  void _applyMeshResolution(int mesh) {
    if (mesh == _config.meshResolution) return;
    final keep = _controller.currentSpread;
    _controller.dispose();
    _controller = BookFlipController(initialSpread: keep);
    setState(() => _config = _config.copyWith(meshResolution: mesh));
  }

  void _toggleAutoFlip(bool on) {
    _autoTimer?.cancel();
    _autoTimer = null;
    if (on) {
      _autoTimer = Timer.periodic(const Duration(milliseconds: 1400), (_) {
        final total = _controller.totalSpreads;
        if (total <= 1) return;
        final spread = _controller.currentSpread;
        if (spread <= 0) {
          _autoDir = 1;
        } else if (spread >= total - 1) {
          _autoDir = -1;
        }
        if (_autoDir > 0) {
          _controller.nextSpread();
        } else {
          _controller.previousSpread();
        }
      });
    }
    setState(() => _autoFlip = on);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF14111B),
      appBar: AppBar(
        title: const Text('book_page_flip · visual stress lab'),
        backgroundColor: Colors.transparent,
        elevation: 0,
      ),
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constraints) {
            final stage = Column(
              children: <Widget>[
                _DiagnosticsBar(controller: _controller),
                Expanded(
                  child: _BookStage(
                    config: _config,
                    controller: _controller,
                    pageBuilder: _pageBuilder,
                  ),
                ),
                _ActionBar(
                  controller: _controller,
                  autoFlip: _autoFlip,
                  onAutoFlip: _toggleAutoFlip,
                ),
              ],
            );
            final panel = _ControlPanel(
              config: _config,
              onChanged: _applyConfig,
              onMeshResolution: _applyMeshResolution,
            );
            if (constraints.maxWidth >= 900) {
              return Row(
                children: <Widget>[
                  Expanded(child: stage),
                  const VerticalDivider(width: 1),
                  SizedBox(width: 380, child: panel),
                ],
              );
            }
            return Column(
              children: <Widget>[
                Expanded(flex: 5, child: stage),
                const Divider(height: 1),
                Expanded(flex: 6, child: panel),
              ],
            );
          },
        ),
      ),
    );
  }
}

// Top-level (stable identity) so toggling labels is the only thing that swaps it
// null↔function and re-captures. `total` is unused; the pill shows just the page.
Widget _labelAt(BuildContext context, int page, int total) =>
    _PageLabel(page: page);

// ─────────────────────────────────────────────────────────────────────────────
//  Config — one immutable source of truth for every book knob.
// ─────────────────────────────────────────────────────────────────────────────

enum _ContentMode {
  numbered('Numbered'),
  grid('Grid'),
  chapters('Chapters');

  const _ContentMode(this.label);
  final String label;
}

enum _MaterialPreset {
  paper('Paper'),
  magazine('Magazine'),
  custom('Custom');

  const _MaterialPreset(this.label);
  final String label;
}

@immutable
class _Config {
  const _Config({
    this.content = _ContentMode.numbered,
    this.pageCount = 8,
    this.fit = BookFit.contain,
    this.preset = _MaterialPreset.paper,
    this.stiffness = 0.62,
    this.weight = 0.0,
    this.gloss = 0.34,
    this.translucency = 0.0,
    this.thickness = 1.0,
    this.useCurl = false,
    this.bend = 0.5,
    this.foldTilt = 0.5,
    this.droop = 0.0,
    this.effects = BookFlipEffects.all,
    this.pixelRatio = 2.0,
    this.maxTextureDimension = 4096,
    this.meshResolution = 42,
    this.showLabels = true,
  });

  final _ContentMode content;
  final int pageCount;
  final BookFit fit;
  final _MaterialPreset preset;
  final double stiffness;
  final double weight;
  final double gloss;
  final double translucency;
  final double thickness;
  final bool useCurl;
  final double bend;
  final double foldTilt;
  final double droop;
  final BookFlipEffects effects;
  final double pixelRatio;
  final int maxTextureDimension;
  final int meshResolution;
  final bool showLabels;

  BookFlipMaterial get material => switch (preset) {
        _MaterialPreset.paper => BookFlipMaterial.paper,
        _MaterialPreset.magazine => BookFlipMaterial.magazine,
        _MaterialPreset.custom => BookFlipMaterial(
            stiffness: stiffness,
            weight: weight,
            gloss: gloss,
            translucency: translucency,
            thickness: thickness,
          ),
      };

  BookFlipCurl? get curl => useCurl
      ? BookFlipCurl(bend: bend, foldTilt: foldTilt, droop: droop)
      : null;

  _Config copyWith({
    _ContentMode? content,
    int? pageCount,
    BookFit? fit,
    _MaterialPreset? preset,
    double? stiffness,
    double? weight,
    double? gloss,
    double? translucency,
    double? thickness,
    bool? useCurl,
    double? bend,
    double? foldTilt,
    double? droop,
    BookFlipEffects? effects,
    double? pixelRatio,
    int? maxTextureDimension,
    int? meshResolution,
    bool? showLabels,
  }) =>
      _Config(
        content: content ?? this.content,
        pageCount: pageCount ?? this.pageCount,
        fit: fit ?? this.fit,
        preset: preset ?? this.preset,
        stiffness: stiffness ?? this.stiffness,
        weight: weight ?? this.weight,
        gloss: gloss ?? this.gloss,
        translucency: translucency ?? this.translucency,
        thickness: thickness ?? this.thickness,
        useCurl: useCurl ?? this.useCurl,
        bend: bend ?? this.bend,
        foldTilt: foldTilt ?? this.foldTilt,
        droop: droop ?? this.droop,
        effects: effects ?? this.effects,
        pixelRatio: pixelRatio ?? this.pixelRatio,
        maxTextureDimension: maxTextureDimension ?? this.maxTextureDimension,
        meshResolution: meshResolution ?? this.meshResolution,
        showLabels: showLabels ?? this.showLabels,
      );
}

// ─────────────────────────────────────────────────────────────────────────────
//  The book under test.
// ─────────────────────────────────────────────────────────────────────────────

class _BookStage extends StatelessWidget {
  const _BookStage({
    required this.config,
    required this.controller,
    required this.pageBuilder,
  });

  final _Config config;
  final BookFlipController controller;
  final Widget Function(BuildContext, int) pageBuilder;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(20),
      child: BookFlip.builder(
        key: ValueKey<int>(config.meshResolution),
        controller: controller,
        pageCount: config.pageCount,
        pageSize: const Size(360, 500),
        pixelRatio: config.pixelRatio,
        maxTextureDimension: config.maxTextureDimension,
        meshResolution: config.meshResolution,
        material: config.material,
        curl: config.curl,
        effects: config.effects,
        fit: config.fit,
        pageBuilder: pageBuilder,
        pageLabel: config.showLabels ? _labelAt : null,
      ),
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
//  Diagnostics + actions — driven by the controller, no mirror state.
// ─────────────────────────────────────────────────────────────────────────────

class _DiagnosticsBar extends StatelessWidget {
  const _DiagnosticsBar({required this.controller});

  final BookFlipController controller;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return ListenableBuilder(
      listenable: controller,
      builder: (context, _) {
        final total = controller.totalSpreads;
        final String text;
        if (total > 0) {
          final cp = controller.currentPage;
          final tp = controller.totalPages;
          final right = cp + 2 <= tp ? cp + 2 : tp;
          text = 'spread ${controller.currentSpread + 1}/$total   ·   '
              'pages ${cp + 1}–$right of $tp   ·   '
              't=${controller.flipProgress.toStringAsFixed(2)}   ·   '
              '${controller.isAnimating ? 'FLIPPING' : 'idle'}';
        } else {
          text = 'opening…';
        }
        return Container(
          width: double.infinity,
          padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
          color: Colors.black.withAlpha(60),
          child: Text(
            text,
            style: theme.textTheme.bodySmall?.copyWith(
              fontFeatures: const <FontFeature>[FontFeature.tabularFigures()],
              color: theme.colorScheme.onSurfaceVariant,
              letterSpacing: 0.3,
            ),
          ),
        );
      },
    );
  }
}

class _ActionBar extends StatelessWidget {
  const _ActionBar({
    required this.controller,
    required this.autoFlip,
    required this.onAutoFlip,
  });

  final BookFlipController controller;
  final bool autoFlip;
  final ValueChanged<bool> onAutoFlip;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.fromLTRB(16, 6, 16, 10),
      child: ListenableBuilder(
        listenable: controller,
        builder: (context, _) {
          final total = controller.totalSpreads;
          final spread = controller.currentSpread;
          final ready = total > 0;
          final canBack = ready && spread > 0;
          final canForward = ready && spread < total - 1;
          final tp = controller.totalPages;
          return Column(
            mainAxisSize: MainAxisSize.min,
            children: <Widget>[
              Wrap(
                spacing: 8,
                runSpacing: 8,
                alignment: WrapAlignment.center,
                crossAxisAlignment: WrapCrossAlignment.center,
                children: <Widget>[
                  _NavButton(
                    icon: Icons.first_page,
                    onPressed: canBack ? () => controller.goToSpread(0) : null,
                  ),
                  _NavButton(
                    icon: Icons.chevron_left,
                    onPressed:
                        canBack ? () => controller.previousSpread() : null,
                  ),
                  _NavButton(
                    icon: Icons.chevron_right,
                    onPressed:
                        canForward ? () => controller.nextSpread() : null,
                  ),
                  _NavButton(
                    icon: Icons.last_page,
                    onPressed: canForward
                        ? () => controller.goToSpread(total - 1)
                        : null,
                  ),
                  FilterChip(
                    avatar: const Icon(Icons.all_inclusive, size: 18),
                    label: const Text('Auto-flip'),
                    selected: autoFlip,
                    onSelected: onAutoFlip,
                  ),
                ],
              ),
              if (tp > 1)
                _CommitSlider(
                  label: 'go to page',
                  value: (controller.currentPage + 1).toDouble(),
                  min: 1,
                  max: tp.toDouble(),
                  divisions: tp - 1,
                  onCommit: (v) => controller.goToPage(v.round() - 1),
                ),
            ],
          );
        },
      ),
    );
  }
}

class _NavButton extends StatelessWidget {
  const _NavButton({required this.icon, required this.onPressed});

  final IconData icon;
  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) {
    return IconButton.filledTonal(
      onPressed: onPressed,
      iconSize: 26,
      icon: Icon(icon),
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
//  Control panel — pure config editing; emits new configs via copyWith.
// ─────────────────────────────────────────────────────────────────────────────

class _ControlPanel extends StatelessWidget {
  const _ControlPanel({
    required this.config,
    required this.onChanged,
    required this.onMeshResolution,
  });

  final _Config config;
  final ValueChanged<_Config> onChanged;
  final ValueChanged<int> onMeshResolution;

  @override
  Widget build(BuildContext context) {
    final effects = config.effects;
    return ListView(
      padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
      children: <Widget>[
        _Section(
          title: 'CONTENT',
          child: SegmentedButton<_ContentMode>(
            showSelectedIcon: false,
            segments: <ButtonSegment<_ContentMode>>[
              for (final mode in _ContentMode.values)
                ButtonSegment<_ContentMode>(
                    value: mode, label: Text(mode.label)),
            ],
            selected: <_ContentMode>{config.content},
            onSelectionChanged: (s) =>
                onChanged(config.copyWith(content: s.first)),
          ),
        ),
        _Section(
          title: 'PAGES — ${config.pageCount}'
              '${config.pageCount.isOdd ? '  (odd: last page unpaired)' : ''}',
          child: _BareSlider(
            value: config.pageCount.toDouble(),
            min: 2,
            max: 30,
            divisions: 28,
            onChanged: (v) => onChanged(config.copyWith(pageCount: v.round())),
          ),
        ),
        _Section(
          title: 'FIT',
          child: SegmentedButton<BookFit>(
            showSelectedIcon: false,
            segments: const <ButtonSegment<BookFit>>[
              ButtonSegment<BookFit>(
                  value: BookFit.contain, label: Text('Contain')),
              ButtonSegment<BookFit>(value: BookFit.fill, label: Text('Fill')),
            ],
            selected: <BookFit>{config.fit},
            onSelectionChanged: (s) => onChanged(config.copyWith(fit: s.first)),
          ),
        ),
        _Section(
          title: 'PIXEL RATIO',
          child: _LabeledSlider(
            label: 'capture density',
            value: config.pixelRatio,
            min: 1,
            max: 3,
            divisions: 8,
            onChanged: (v) => onChanged(config.copyWith(pixelRatio: v)),
          ),
        ),
        _Section(
          title: 'TEXTURE CAP — ${config.maxTextureDimension}px atlas',
          child: _CommitSlider(
            label: 'max texture',
            value: config.maxTextureDimension.toDouble(),
            min: 256,
            max: 4096,
            divisions: 15,
            onCommit: (v) =>
                onChanged(config.copyWith(maxTextureDimension: v.round())),
          ),
        ),
        _Section(
          title: 'MESH — ${config.meshResolution} cols (reopens the book)',
          child: _CommitSlider(
            label: 'tessellation',
            value: config.meshResolution.toDouble(),
            min: 8,
            max: 300,
            divisions: 292,
            onCommit: (v) => onMeshResolution(v.round()),
          ),
        ),
        _ToggleTile(
          label: 'Page labels',
          value: config.showLabels,
          onChanged: (v) => onChanged(config.copyWith(showLabels: v)),
        ),
        _Section(
          title: 'PAPER',
          child: SegmentedButton<_MaterialPreset>(
            showSelectedIcon: false,
            segments: <ButtonSegment<_MaterialPreset>>[
              for (final preset in _MaterialPreset.values)
                ButtonSegment<_MaterialPreset>(
                    value: preset, label: Text(preset.label)),
            ],
            selected: <_MaterialPreset>{config.preset},
            onSelectionChanged: (s) =>
                onChanged(config.copyWith(preset: s.first)),
          ),
        ),
        if (config.preset == _MaterialPreset.custom) ...<Widget>[
          _LabeledSlider(
            label: 'stiffness',
            value: config.stiffness,
            onChanged: (v) => onChanged(config.copyWith(stiffness: v)),
          ),
          _LabeledSlider(
            label: 'weight',
            value: config.weight,
            onChanged: (v) => onChanged(config.copyWith(weight: v)),
          ),
          _LabeledSlider(
            label: 'gloss',
            value: config.gloss,
            onChanged: (v) => onChanged(config.copyWith(gloss: v)),
          ),
          _LabeledSlider(
            label: 'translucency',
            value: config.translucency,
            onChanged: (v) => onChanged(config.copyWith(translucency: v)),
          ),
          _LabeledSlider(
            label: 'thickness',
            value: config.thickness,
            min: 0,
            max: 3,
            onChanged: (v) => onChanged(config.copyWith(thickness: v)),
          ),
        ],
        _ToggleTile(
          label: 'Override page-curve (curl)',
          value: config.useCurl,
          onChanged: (v) => onChanged(config.copyWith(useCurl: v)),
        ),
        if (config.useCurl) ...<Widget>[
          _LabeledSlider(
            label: 'bend',
            value: config.bend,
            onChanged: (v) => onChanged(config.copyWith(bend: v)),
          ),
          _LabeledSlider(
            label: 'fold tilt',
            value: config.foldTilt,
            onChanged: (v) => onChanged(config.copyWith(foldTilt: v)),
          ),
          _LabeledSlider(
            label: 'droop',
            value: config.droop,
            onChanged: (v) => onChanged(config.copyWith(droop: v)),
          ),
        ],
        _Section(
          title: 'EFFECTS',
          child: Column(
            children: <Widget>[
              _ToggleTile(
                label: 'Gloss',
                value: effects.gloss,
                onChanged: (v) => onChanged(
                    config.copyWith(effects: effects.copyWith(gloss: v))),
              ),
              _ToggleTile(
                label: 'Grain',
                value: effects.grain,
                onChanged: (v) => onChanged(
                    config.copyWith(effects: effects.copyWith(grain: v))),
              ),
              _ToggleTile(
                label: 'Cast shadow',
                value: effects.castShadow,
                onChanged: (v) => onChanged(
                    config.copyWith(effects: effects.copyWith(castShadow: v))),
              ),
              _ToggleTile(
                label: 'Spine shadow',
                value: effects.spineShadow,
                onChanged: (v) => onChanged(
                    config.copyWith(effects: effects.copyWith(spineShadow: v))),
              ),
              _ToggleTile(
                label: 'Edge line',
                value: effects.edge,
                onChanged: (v) => onChanged(
                    config.copyWith(effects: effects.copyWith(edge: v))),
              ),
              _ToggleTile(
                label: 'Translucency',
                value: effects.translucency,
                onChanged: (v) => onChanged(config.copyWith(
                    effects: effects.copyWith(translucency: v))),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class _Section extends StatelessWidget {
  const _Section({required this.title, required this.child});

  final String title;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Padding(
      padding: const EdgeInsets.only(top: 18),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            title,
            style: theme.textTheme.labelSmall?.copyWith(
              letterSpacing: 1.2,
              fontWeight: FontWeight.w700,
              color: theme.colorScheme.primary,
            ),
          ),
          const SizedBox(height: 8),
          child,
        ],
      ),
    );
  }
}

class _LabeledSlider extends StatelessWidget {
  const _LabeledSlider({
    required this.label,
    required this.value,
    required this.onChanged,
    this.min = 0,
    this.max = 1,
    this.divisions,
  });

  final String label;
  final double value;
  final ValueChanged<double> onChanged;
  final double min;
  final double max;
  final int? divisions;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Row(
      children: <Widget>[
        SizedBox(
          width: 96,
          child: Text(label, style: theme.textTheme.bodySmall),
        ),
        Expanded(
          child: Slider(
            value: value.clamp(min, max),
            min: min,
            max: max,
            divisions: divisions,
            label: value.toStringAsFixed(2),
            onChanged: onChanged,
          ),
        ),
        SizedBox(
          width: 38,
          child: Text(
            value.toStringAsFixed(2),
            textAlign: TextAlign.end,
            style: theme.textTheme.bodySmall?.copyWith(
              fontFeatures: const <FontFeature>[FontFeature.tabularFigures()],
            ),
          ),
        ),
      ],
    );
  }
}

class _BareSlider extends StatelessWidget {
  const _BareSlider({
    required this.value,
    required this.onChanged,
    required this.min,
    required this.max,
    required this.divisions,
  });

  final double value;
  final ValueChanged<double> onChanged;
  final double min;
  final double max;
  final int divisions;

  @override
  Widget build(BuildContext context) {
    return Slider(
      value: value.clamp(min, max),
      min: min,
      max: max,
      divisions: divisions,
      label: value.round().toString(),
      onChanged: onChanged,
    );
  }
}

// A slider that reports only on release: it tracks the drag locally so the value
// stays smooth, but fires [onCommit] once — used for the costly knobs (texture
// cap re-captures; mesh resolution reopens the book) so a drag does not thrash.
class _CommitSlider extends StatefulWidget {
  const _CommitSlider({
    required this.label,
    required this.value,
    required this.min,
    required this.max,
    required this.divisions,
    required this.onCommit,
  });

  final String label;
  final double value;
  final double min;
  final double max;
  final int divisions;
  final ValueChanged<double> onCommit;

  @override
  State<_CommitSlider> createState() => _CommitSliderState();
}

class _CommitSliderState extends State<_CommitSlider> {
  double? _drag;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final shown = (_drag ?? widget.value).clamp(widget.min, widget.max);
    return Row(
      children: <Widget>[
        SizedBox(
          width: 96,
          child: Text(widget.label, style: theme.textTheme.bodySmall),
        ),
        Expanded(
          child: Slider(
            value: shown,
            min: widget.min,
            max: widget.max,
            divisions: widget.divisions,
            label: shown.round().toString(),
            onChanged: (v) => setState(() => _drag = v),
            onChangeEnd: (v) {
              setState(() => _drag = null);
              widget.onCommit(v);
            },
          ),
        ),
        SizedBox(
          width: 42,
          child: Text(
            shown.round().toString(),
            textAlign: TextAlign.end,
            style: theme.textTheme.bodySmall?.copyWith(
              fontFeatures: const <FontFeature>[FontFeature.tabularFigures()],
            ),
          ),
        ),
      ],
    );
  }
}

class _ToggleTile extends StatelessWidget {
  const _ToggleTile({
    required this.label,
    required this.value,
    required this.onChanged,
  });

  final String label;
  final bool value;
  final ValueChanged<bool> onChanged;

  @override
  Widget build(BuildContext context) {
    return SwitchListTile(
      dense: true,
      contentPadding: EdgeInsets.zero,
      title: Text(label),
      value: value,
      onChanged: onChanged,
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
//  Page content — three modes, each a plain widget the book rasterises.
// ─────────────────────────────────────────────────────────────────────────────

class _StressPage extends StatelessWidget {
  const _StressPage({required this.mode, required this.index});

  final _ContentMode mode;
  final int index;

  @override
  Widget build(BuildContext context) => switch (mode) {
        _ContentMode.numbered => _NumberedPage(index: index),
        _ContentMode.grid => _GridPage(index: index),
        _ContentMode.chapters =>
          _LeafView(leaf: _leaves[index % _leaves.length]),
      };
}

// Big cycling-hue number — easy to track page identity and spot colour blinks.
class _NumberedPage extends StatelessWidget {
  const _NumberedPage({required this.index});

  final int index;

  @override
  Widget build(BuildContext context) {
    final hue = (index * 47.0) % 360.0;
    return DecoratedBox(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: <Color>[
            HSVColor.fromAHSV(1, hue, 0.55, 0.52).toColor(),
            HSVColor.fromAHSV(1, (hue + 24) % 360, 0.70, 0.30).toColor(),
          ],
        ),
      ),
      child: Center(
        child: Text(
          '${index + 1}',
          style: const TextStyle(
            color: Colors.white,
            fontSize: 120,
            fontWeight: FontWeight.w800,
          ),
        ),
      ),
    );
  }
}

// Ruled grid + corner tags — exposes mesh distortion, seams and mirroring.
class _GridPage extends StatelessWidget {
  const _GridPage({required this.index});

  final int index;

  @override
  Widget build(BuildContext context) {
    final hue = (index * 47.0) % 360.0;
    return ColoredBox(
      color: HSVColor.fromAHSV(1, hue, 0.16, 0.97).toColor(),
      child: Stack(
        fit: StackFit.expand,
        children: <Widget>[
          const GridPaper(color: Colors.black26, interval: 60, subdivisions: 1),
          const _CornerTag(alignment: Alignment.topLeft, text: 'TL'),
          const _CornerTag(alignment: Alignment.topRight, text: 'TR'),
          const _CornerTag(alignment: Alignment.bottomLeft, text: 'BL'),
          const _CornerTag(alignment: Alignment.bottomRight, text: 'BR'),
          Center(
            child: Text(
              '${index + 1}',
              style: TextStyle(
                color: Colors.black.withAlpha(120),
                fontSize: 72,
                fontWeight: FontWeight.w800,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _CornerTag extends StatelessWidget {
  const _CornerTag({required this.alignment, required this.text});

  final AlignmentGeometry alignment;
  final String text;

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: alignment,
      child: Padding(
        padding: const EdgeInsets.all(10),
        child: Text(
          text,
          style: const TextStyle(
            color: Colors.black54,
            fontSize: 16,
            fontWeight: FontWeight.w800,
          ),
        ),
      ),
    );
  }
}

class _PageLabel extends StatelessWidget {
  const _PageLabel({required this.page});

  final int page;

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.bottomCenter,
      child: Padding(
        padding: const EdgeInsets.only(bottom: 14),
        child: DecoratedBox(
          decoration: BoxDecoration(
            color: Colors.black.withAlpha(70),
            borderRadius: BorderRadius.circular(10),
          ),
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
            child: Text(
              '$page',
              style: const TextStyle(
                color: Colors.white,
                fontSize: 12,
                fontWeight: FontWeight.w600,
                letterSpacing: 1,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
//  Realistic widget-page content for the "Chapters" mode.
// ─────────────────────────────────────────────────────────────────────────────

class _LeafView extends StatelessWidget {
  const _LeafView({required this.leaf});

  final _Leaf leaf;

  static const Color _paper = Color(0xFFF7F1E6);
  static const Color _ink = Color(0xFF2A2520);
  static const Color _bodyInk = Color(0xFF4A4136);

  @override
  Widget build(BuildContext context) {
    return ColoredBox(
      color: _paper,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(30, 34, 30, 40),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            _LeafHeader(leaf: leaf),
            const SizedBox(height: 24),
            Text(
              leaf.title,
              style: const TextStyle(
                color: _ink,
                fontSize: 30,
                height: 1.1,
                fontWeight: FontWeight.w700,
              ),
            ),
            const SizedBox(height: 18),
            Expanded(
              child: Text(
                leaf.body,
                style: const TextStyle(
                  color: _bodyInk,
                  fontSize: 16,
                  height: 1.55,
                ),
              ),
            ),
            _LeafRule(color: leaf.accent),
          ],
        ),
      ),
    );
  }
}

class _LeafHeader extends StatelessWidget {
  const _LeafHeader({required this.leaf});

  final _Leaf leaf;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Container(
          width: 44,
          height: 44,
          decoration: BoxDecoration(
            color: leaf.accent.withAlpha(38),
            borderRadius: BorderRadius.circular(12),
          ),
          child: Icon(leaf.icon, color: leaf.accent, size: 24),
        ),
        const SizedBox(width: 14),
        Text(
          leaf.kicker,
          style: TextStyle(
            color: leaf.accent,
            fontSize: 12,
            fontWeight: FontWeight.w600,
            letterSpacing: 2,
          ),
        ),
      ],
    );
  }
}

class _LeafRule extends StatelessWidget {
  const _LeafRule({required this.color});

  final Color color;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 48,
      height: 3,
      decoration: BoxDecoration(
        color: color,
        borderRadius: BorderRadius.circular(2),
      ),
    );
  }
}

@immutable
class _Leaf {
  const _Leaf({
    required this.kicker,
    required this.title,
    required this.body,
    required this.icon,
    required this.accent,
  });

  final String kicker;
  final String title;
  final String body;
  final IconData icon;
  final Color accent;
}

const List<_Leaf> _leaves = <_Leaf>[
  _Leaf(
    kicker: 'CHAPTER ONE',
    title: 'Paper & Light',
    body: 'A page is a thin plane of light. Hold it to a window and the world '
        'behind it bleeds through the fibres; lay it flat and it keeps its own '
        'quiet glow.',
    icon: Icons.wb_sunny_outlined,
    accent: Color(0xFFE0A106),
  ),
  _Leaf(
    kicker: 'CHAPTER TWO',
    title: 'The Spine',
    body: 'Every book turns on its spine — the still valley where two pages '
        'meet. Light pools there, a soft shadow that tells the eye exactly '
        'where the fold lives.',
    icon: Icons.menu_book_outlined,
    accent: Color(0xFF5C6BC0),
  ),
  _Leaf(
    kicker: 'CHAPTER THREE',
    title: 'Grain & Tooth',
    body: 'Drag a thumb across uncoated stock and you feel the tooth: a faint '
        'mottle that scatters light. It is the fingerprint of the paper, '
        'strongest where the page bends.',
    icon: Icons.texture,
    accent: Color(0xFF2B9C8A),
  ),
  _Leaf(
    kicker: 'CHAPTER FOUR',
    title: 'The Weight of a Page',
    body: 'A heavy leaf falls slowly, its free corner drooping under its own '
        'weight. A light one snaps. Weight is the difference between newsprint '
        'and a board cover.',
    icon: Icons.fitness_center,
    accent: Color(0xFFCB6B3A),
  ),
  _Leaf(
    kicker: 'CHAPTER FIVE',
    title: 'Gloss',
    body: 'Coated stock answers the light with a single bright glint that '
        'slides along the curl. Matte paper only whispers back. Gloss is how a '
        'surface says it is smooth.',
    icon: Icons.auto_awesome,
    accent: Color(0xFFC2477E),
  ),
  _Leaf(
    kicker: 'CHAPTER SIX',
    title: 'The Turn',
    body: 'A page in motion is never flat. It lifts, curls, and lays itself '
        'down on the far side — a developable surface that bends in one '
        'direction and never tears.',
    icon: Icons.gesture,
    accent: Color(0xFF7C4DFF),
  ),
  _Leaf(
    kicker: 'CHAPTER SEVEN',
    title: 'Shadow',
    body: 'The lifting leaf throws a soft shadow on the page beneath, darkest '
        'at the hinge. Remove it and the book goes flat; restore it and depth '
        'returns at once.',
    icon: Icons.dark_mode_outlined,
    accent: Color(0xFF5B7186),
  ),
  _Leaf(
    kicker: 'CHAPTER EIGHT',
    title: 'Rest',
    body: 'When the turn completes, the page settles until it is '
        'indistinguishable from the one below. A good turn ends in perfect '
        'stillness: no seam, no pop, no trace.',
    icon: Icons.spa_outlined,
    accent: Color(0xFF3E8E5A),
  ),
];
11
likes
160
points
62
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A smooth, efficient open-book page-turn widget. Drag or animate pages with a realistic 3D page curl, a soft shadow, and a natural-feeling turn.

Repository (GitHub)
View/report issues

Topics

#animation #book #page-flip #ui #widget

License

MIT (license)

Dependencies

flutter, vector_math

More

Packages that depend on book_page_flip