reading_progress 0.0.1
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.
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)),
],
),
);
}