Skip to content

Instantly share code, notes, and snippets.

@callmephil
Created January 31, 2025 09:01
Show Gist options
  • Save callmephil/99f6118d3d0475293bbb689c15aaf662 to your computer and use it in GitHub Desktop.
Save callmephil/99f6118d3d0475293bbb689c15aaf662 to your computer and use it in GitHub Desktop.
Text Reader Prototype
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
void main() {
runApp(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Text Reader')),
body: const TextReaderPrototype(
text: 'This is a long paragraph used to test the text reader widget. '
'The text will be highlighted word by word based on the selected '
'reading speed. If the paragraph overflows its parent container, '
'it will handle the scrolling behavior by gradually scrolling to '
'the next line as more words are highlighted.\n\nThis ensures that '
'all the highlighted text remains always visible to the user.\n',
),
),
),
);
}
class TextReaderPrototype extends StatefulWidget {
const TextReaderPrototype({super.key, required this.text});
final String text;
@override
State<TextReaderPrototype> createState() => _TextReaderPrototypeState();
}
class _TextReaderPrototypeState extends State<TextReaderPrototype> {
int _currentWordIndex = 0;
double _readingSpeed = 100; // Words per minute
Timer? _timer;
late List<String> _words;
bool _isPlaying = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_words = widget.text.split(' ');
}
void _startReading() {
_timer?.cancel();
if (_isPlaying) {
_timer = Timer.periodic(
Duration(milliseconds: (60000 / _readingSpeed).round()),
(timer) {
setState(() {
if (_currentWordIndex < _words.length) {
_currentWordIndex++;
_scrollToNextSentenceIfNeeded();
} else {
_isPlaying = false;
timer.cancel();
}
});
},
);
}
}
void _onWordTap(int index) {
setState(() {
_currentWordIndex = index;
_isPlaying = true;
_startReading();
_scrollToNextSentenceIfNeeded();
});
}
void _play() {
setState(() {
_isPlaying = true;
_startReading();
});
}
void _pause() {
setState(() {
_isPlaying = false;
_timer?.cancel();
});
}
void _restart() {
_pause();
setState(() {
_currentWordIndex = 0;
_scrollToNextSentenceIfNeeded();
});
}
void _scrollToNextSentenceIfNeeded() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final textPainter = TextPainter(
text: TextSpan(
text: '${_words.take(_currentWordIndex + 1).join(' ')} ',
style: const TextStyle(
backgroundColor: Colors.yellow,
color: Colors.black,
),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: _scrollController.position.viewportDimension);
// Calculate the height of two lines of text
final twoLinesHeight = textPainter.preferredLineHeight;
final scrollPosition = textPainter.size.height - 24;
// Only scroll if the scrollPosition is greater than the height of two lines
if (scrollPosition > twoLinesHeight) {
final maxScrollExtent = _scrollController.position.maxScrollExtent ;
// Adjust scrollPosition if it overflows maxScrollExtent
final adjustedScrollPosition =
scrollPosition > maxScrollExtent ? maxScrollExtent : scrollPosition;
_scrollController.animateTo(
adjustedScrollPosition,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
});
}
@override
void dispose() {
_timer?.cancel();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: SingleChildScrollView(
controller: _scrollController,
child: HighlightedText(
words: _words,
currentWordIndex: _currentWordIndex,
onWordTap: _onWordTap,
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!_isPlaying)
IconButton(
icon: const Icon(Icons.play_arrow),
onPressed: _play,
),
if (_isPlaying)
IconButton(icon: const Icon(Icons.pause), onPressed: _pause),
IconButton(
icon: const Icon(Icons.restart_alt),
onPressed: _restart,
),
],
),
Slider(
value: _readingSpeed,
min: 1,
max: 400,
divisions: 400,
label: '${_readingSpeed.round()} WPM',
onChanged: (value) {
setState(() {
_readingSpeed = value;
if (_isPlaying) {
_startReading();
}
});
},
),
Text('Speed: ${_readingSpeed.round()} WPM'),
],
);
}
}
// ignore: prefer-single-widget-per-file
class HighlightedText extends StatelessWidget {
const HighlightedText({
super.key,
required this.words,
required this.currentWordIndex,
required this.onWordTap,
});
final List<String> words;
final int currentWordIndex;
final void Function(int) onWordTap;
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
children: words.mapIndexed((i, e) {
final gestureRecognizer = TapGestureRecognizer()
..onTap = () => onWordTap(i);
return TextSpan(
text: '$e ',
style: TextStyle(
backgroundColor:
i < currentWordIndex ? Colors.yellow : Colors.transparent,
color: Colors.black,
fontSize: 24,
),
recognizer: gestureRecognizer,
);
}).toList(growable: false),
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment