reading_progress 0.0.1 copy "reading_progress: ^0.0.1" to clipboard
reading_progress: ^0.0.1 copied to clipboard

A pure Dart engine for verified reading progress — confident-word milestones, active-dwell tracking, and reading-speed calibration.

example/lib/main.dart

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'reading_progress demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),
      home: const DemoScreen(),
    );
  }
}

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

  @override
  State<DemoScreen> createState() => _DemoScreenState();
}

class _DemoScreenState extends State<DemoScreen> {
  static const _density = BookDensity(
    pageWords: [120, 600, 950, 30, 1100, 800, 450, 700],
    skippablePages: {3},
    genre: Genre.nonFiction,
  );

  late final ReadingDeps _deps = ReadingDeps(density: _density);
  late ReadingState _state = ReadingState.initial(_deps);
  int _nowMs = 0;
  final List<String> _log = [];

  int get _page => _state.currentPage ?? 0;

  int _tick(int ms) {
    _nowMs += ms;
    return _nowMs;
  }

  void _apply(ReadingEvent event) {
    final out = reduce(_state, event, _deps);
    setState(() {
      _state = out.state;
      for (final effect in out.effects) {
        _log.insert(0, '${_nowMs ~/ 1000}s  ${_describe(effect)}');
      }
    });
  }

  String _describe(ProgressEffect effect) => switch (effect) {
    PageCompleted(:final page) => '✓ page $page completed',
    PointBanked(:final point, :final wordsRead) =>
      '• point $point banked ($wordsRead words)',
    MilestoneReached(:final index, :final wordsRead) =>
      '★ milestone $index reached ($wordsRead words)',
    BookCompleted() => '🏁 BOOK COMPLETED',
  };

  @override
  void initState() {
    super.initState();
    _apply(PageOpened(page: 0, atMs: _tick(0)));
  }

  void _readSlow() => _apply(
    Interaction(
      kind: InteractionKind.scroll,
      atMs: _tick(2000),
      scrollVelocity: 400,
    ),
  );

  void _skim() => _apply(
    Interaction(
      kind: InteractionKind.scroll,
      atMs: _tick(900),
      scrollVelocity: 5000,
    ),
  );

  void _tap() =>
      _apply(Interaction(kind: InteractionKind.tap, atMs: _tick(3000)));

  void _silentRead() => _apply(Tick(atMs: _tick(10000)));

  void _idle() => _tick(60000);

  void _background() => _apply(AppBackgrounded(atMs: _tick(0)));

  void _next() {
    if (_page < _density.pageCount - 1) {
      _apply(PageOpened(page: _page + 1, atMs: _tick(500)));
    }
  }

  void _prev() {
    if (_page > 0) _apply(PageOpened(page: _page - 1, atMs: _tick(500)));
  }

  void _exitForward() => _apply(
    PageExited(page: _page, direction: ExitDirection.forward, atMs: _tick(0)),
  );

  void _autoRead() {
    final words = _density.wordsAt(_page);
    final expectedMs = (words / _state.wpm * 60000).round();
    final target = (expectedMs * _deps.config.k).ceil();
    var spent = 0;
    while (spent < target + 3000) {
      _apply(Interaction(kind: InteractionKind.tap, atMs: _tick(3000)));
      spent += 3000;
    }
    if (_page < _density.pageCount - 1) {
      _next();
    } else {
      _exitForward();
    }
  }

  void _reset() {
    setState(() {
      _nowMs = 0;
      _state = ReadingState.initial(_deps);
      _log.clear();
    });
    _apply(PageOpened(page: 0, atMs: _tick(0)));
  }

  @override
  Widget build(BuildContext context) {
    final open = _state.open;
    final words = _density.wordsAt(_page);
    final expectedMs = (words / _state.wpm * 60000).round();

    return Scaffold(
      appBar: AppBar(
        title: const Text('reading_progress engine'),
        actions: [
          IconButton(onPressed: _reset, icon: const Icon(Icons.refresh)),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: ListView(
          children: [
            _StatCard(
              page: _page,
              total: _density.pageCount,
              words: words,
              engagedMs: open?.engagedMs,
              sessionEngagedMs: _state.sessionEngagedMs,
              expectedMs: expectedMs,
              wpm: _state.wpm,
              wordsRead: _state.wordsRead,
              totalWords: _density.totalWords,
              progress: _state.progressByWords(_density),
              points: _state.pointsBanked,
              milestones: _state.milestonesReached,
              totalMilestones: _state.totalMilestones(_density, _deps.config),
              calibrating: _state.isCalibrating(_deps.config),
              completed: _state.completedPages,
              skippable: _density.skippablePages,
              bookDone: _state.bookCompleted,
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                _btn('Tap +3s', _tap),
                _btn('Read silently +10s', _silentRead, tonal: true),
                _btn('Slow scroll +2s', _readSlow),
                _btn('Skim +0.9s', _skim),
                _btn('Auto-read page', _autoRead, tonal: true),
                _btn('Idle 60s', _idle),
                _btn('Background', _background),
                _btn('Exit ▸ forward', _exitForward),
                _btn('◂ Prev', _prev),
                _btn('Next ▸', _next),
              ],
            ),
            const SizedBox(height: 16),
            Text('Effect log', style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 4),
            if (_log.isEmpty) const Text('—'),
            for (final line in _log)
              Padding(
                padding: const EdgeInsets.symmetric(vertical: 2),
                child: Text(
                  line,
                  style: const TextStyle(fontFamily: 'monospace'),
                ),
              ),
          ],
        ),
      ),
    );
  }

  Widget _btn(String label, VoidCallback onTap, {bool tonal = false}) {
    return tonal
        ? FilledButton.tonal(onPressed: onTap, child: Text(label))
        : OutlinedButton(onPressed: onTap, child: Text(label));
  }
}

class _StatCard extends StatelessWidget {
  const _StatCard({
    required this.page,
    required this.total,
    required this.words,
    required this.engagedMs,
    required this.sessionEngagedMs,
    required this.expectedMs,
    required this.wpm,
    required this.wordsRead,
    required this.totalWords,
    required this.progress,
    required this.points,
    required this.milestones,
    required this.totalMilestones,
    required this.calibrating,
    required this.completed,
    required this.skippable,
    required this.bookDone,
  });

  final int page;
  final int total;
  final int words;
  final int? engagedMs;
  final int sessionEngagedMs;
  final int expectedMs;
  final double wpm;
  final int wordsRead;
  final int totalWords;
  final double progress;
  final int points;
  final int milestones;
  final int totalMilestones;
  final bool calibrating;
  final Set<int> completed;
  final Set<int> skippable;
  final bool bookDone;

  @override
  Widget build(BuildContext context) {
    final engaged = engagedMs;
    final ratio = (engaged == null || expectedMs == 0)
        ? null
        : engaged / expectedMs;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  'Page $page / ${total - 1}',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                if (skippable.contains(page))
                  const Chip(label: Text('skippable')),
                if (calibrating) const Chip(label: Text('calibrating')),
                if (bookDone) const Chip(label: Text('BOOK DONE')),
              ],
            ),
            const SizedBox(height: 8),
            LinearProgressIndicator(value: progress),
            const SizedBox(height: 4),
            Text(
              'Book progress ${(progress * 100).toStringAsFixed(1)}%  '
              '($wordsRead / $totalWords words)',
            ),
            const Divider(),
            _row('words on page', '$words'),
            _row(
              'engaged (this page)',
              engaged == null
                  ? '— (page closed)'
                  : '${(engaged / 1000).toStringAsFixed(1)}s',
            ),
            _row('expected', '${(expectedMs / 1000).toStringAsFixed(1)}s'),
            _row('dwell ratio', ratio == null ? '—' : ratio.toStringAsFixed(2)),
            _row(
              'session engaged',
              '${(sessionEngagedMs / 1000).toStringAsFixed(1)}s',
            ),
            _row('wpm (calibrated)', wpm.toStringAsFixed(1)),
            _row('points banked', '$points'),
            _row('milestones', '$milestones / $totalMilestones'),
            _row('completed pages', completed.isEmpty ? '—' : '$completed'),
          ],
        ),
      ),
    );
  }

  Widget _row(String k, String v) => Padding(
    padding: const EdgeInsets.symmetric(vertical: 2),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Text(k),
        Text(v, style: const TextStyle(fontWeight: FontWeight.w600)),
      ],
    ),
  );
}
0
likes
160
points
0
downloads

Documentation

API reference

Publisher

verified publishercodeswot.dev

Weekly Downloads

A pure Dart engine for verified reading progress — confident-word milestones, active-dwell tracking, and reading-speed calibration.

Repository (GitHub)
View/report issues

Topics

#reading #progress #engagement #ereader #tracking

License

MIT (license)

Dependencies

equatable

More

Packages that depend on reading_progress