pubspec.yaml
name: readaloud
description: "ReadAloud"
publish_to: 'none'
version: 2.0.1+17
environment:
sdk: ^3.9.2
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
cupertino_icons: ^1.0.8
intl: ^0.20.2
provider: ^6.1.2
shared_preferences: ^2.3.2
flutter_tts: ^4.0.2
google_mobile_ads: ^6.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.3 #flutter pub run flutter_launcher_icons
flutter_native_splash: ^2.3.6 #flutter pub run flutter_native_splash:create
flutter_icons:
android: "launcher_icon"
ios: true
image_path: "assets/icon/icon.png"
adaptive_icon_background: "assets/icon/icon_back.png"
adaptive_icon_foreground: "assets/icon/icon_fore.png"
flutter_native_splash:
color: '#9da9f5'
image: 'assets/image/splash.png'
color_dark: '#9da9f5'
image_dark: 'assets/image/splash.png'
fullscreen: true
android_12:
icon_background_color: '#9da9f5'
image: 'assets/image/splash.png'
icon_background_color_dark: '#9da9f5'
image_dark: 'assets/image/splash.png'
flutter:
uses-material-design: true
generate: true
assets:
- assets/image/
lib/ad_banner_widget.dart
import 'package:flutter/cupertino.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:readaloud/ad_manager.dart';
class AdBannerWidget extends StatefulWidget {
final AdManager adManager;
const AdBannerWidget({super.key, required this.adManager});
@override
State<AdBannerWidget> createState() => _AdBannerWidgetState();
}
class _AdBannerWidgetState extends State<AdBannerWidget> {
int _lastBannerWidthDp = 0;
bool _isAdLoaded = false;
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final int width = constraints.maxWidth.isFinite
? constraints.maxWidth.truncate()
: MediaQuery.of(context).size.width.truncate();
final bannerAd = widget.adManager.bannerAd;
if (width > 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
final bannerAd = widget.adManager.bannerAd;
final bool widthChanged = _lastBannerWidthDp != width;
final bool sizeMismatch =
bannerAd == null || bannerAd.size.width != width;
if ((widthChanged || !_isAdLoaded || sizeMismatch) &&
!_isLoading) {
_lastBannerWidthDp = width;
setState(() {
_isAdLoaded = false;
_isLoading = true;
});
widget.adManager.loadAdaptiveBannerAd(width, () {
if (mounted) {
setState(() {
_isAdLoaded = true;
_isLoading = false;
});
}
});
}
}
});
}
if (_isAdLoaded && bannerAd != null) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: bannerAd.size.width.toDouble(),
height: bannerAd.size.height.toDouble(),
child: AdWidget(ad: bannerAd),
),
],
)
]
);
} else {
return const SizedBox.shrink();
}
},
),
);
}
}
lib/ad_manager.dart
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:ui';
import 'package:google_mobile_ads/google_mobile_ads.dart';
class AdManager {
// Test IDs
// static const String _androidAdUnitId = "ca-app-pub-3940256099942544/6300978111";
// static const String _iosAdUnitId = "ca-app-pub-3940256099942544/2934735716";
// Production IDs
static const String _androidAdUnitId = "ca-app-pub-0/0";
static const String _iosAdUnitId = "ca-app-pub-0/0";
static String get _adUnitId =>
Platform.isIOS ? _iosAdUnitId : _androidAdUnitId;
BannerAd? _bannerAd;
int _lastWidthPx = 0;
VoidCallback? _onLoadedCb;
Timer? _retryTimer;
int _retryAttempt = 0;
BannerAd? get bannerAd => _bannerAd;
Future<void> loadAdaptiveBannerAd(
int widthPx,
VoidCallback onAdLoaded,
) async {
_onLoadedCb = onAdLoaded;
_lastWidthPx = widthPx;
_retryAttempt = 0;
_retryTimer?.cancel();
_startLoad(widthPx);
}
Future<void> _startLoad(int widthPx) async {
_bannerAd?.dispose();
AnchoredAdaptiveBannerAdSize? adaptiveSize;
try {
adaptiveSize =
await AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(
widthPx,
);
} catch (_) {
adaptiveSize = null;
}
final AdSize size = adaptiveSize ?? AdSize.fullBanner;
_bannerAd = BannerAd(
adUnitId: _adUnitId,
request: const AdRequest(),
size: size,
listener: BannerAdListener(
onAdLoaded: (ad) {
_retryTimer?.cancel();
_retryAttempt = 0;
final cb = _onLoadedCb;
if (cb != null) {
cb();
}
},
onAdFailedToLoad: (ad, err) {
ad.dispose();
_scheduleRetry();
},
),
)..load();
}
void _scheduleRetry() {
_retryTimer?.cancel();
// Exponential backoff: 3s, 6s, 12s, max 30s
_retryAttempt = (_retryAttempt + 1).clamp(1, 5);
final seconds = _retryAttempt >= 4 ? 30 : (3 << (_retryAttempt - 1));
_retryTimer = Timer(Duration(seconds: seconds), () {
_startLoad(_lastWidthPx > 0 ? _lastWidthPx : 320);
});
}
void dispose() {
_bannerAd?.dispose();
_retryTimer?.cancel();
}
}
lib/app_settings.dart
import "package:flutter/material.dart";
import "package:shared_preferences/shared_preferences.dart";
class AppSettings extends ChangeNotifier {
AppSettings(this._prefs);
static const _themeKey = "themeNumber";
static const _localeKey = "localeLanguage";
final SharedPreferences _prefs;
ThemeMode _themeMode = ThemeMode.light;
Locale? _locale;
ThemeMode get themeMode => _themeMode;
Locale? get locale => _locale;
bool get isDarkTheme => _themeMode == ThemeMode.dark;
Future<void> load() async {
final themeNumber = _prefs.getInt(_themeKey);
switch (themeNumber) {
case 1:
_themeMode = ThemeMode.dark;
break;
case 2:
_themeMode = ThemeMode.system;
break;
default:
_themeMode = ThemeMode.light;
break;
}
final localeCode = _prefs.getString(_localeKey) ?? "";
_locale = localeCode.isNotEmpty ? Locale(localeCode) : null;
}
void setThemeMode(ThemeMode mode) {
if (_themeMode == mode) {
return;
}
_themeMode = mode;
final value = switch (mode) {
ThemeMode.dark => 1,
ThemeMode.system => 2,
_ => 0,
};
_prefs.setInt(_themeKey, value);
notifyListeners();
}
void setDarkTheme(bool isDark) =>
setThemeMode(isDark ? ThemeMode.dark : ThemeMode.light);
void setLocaleCode(String? code) {
final normalized = code?.trim() ?? "";
final newLocale = normalized.isEmpty ? null : Locale(normalized);
if (_locale == newLocale) {
return;
}
_locale = newLocale;
if (normalized.isEmpty) {
_prefs.remove(_localeKey);
} else {
_prefs.setString(_localeKey, normalized);
}
notifyListeners();
}
void useSystemLocale() => setLocaleCode(null);
}
lib/home_page.dart
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "l10n/app_localizations.dart";
import "package:readaloud/ad_manager.dart";
import "package:readaloud/ad_banner_widget.dart";
import "package:readaloud/settings_page.dart";
import "package:readaloud/speech_controller.dart";
import "package:readaloud/text_tabs_controller.dart";
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
late AdManager _adManager;
late final TextEditingController _textController;
late final FocusNode _textFocusNode;
bool _applyingExternalText = false;
int? _lastActiveTab;
int _lastSpeechEventToken = 0;
bool _themeLight = true;
@override
void initState() {
super.initState();
_adManager = AdManager();
WidgetsBinding.instance.addObserver(this);
_textController = TextEditingController();
_textFocusNode = FocusNode();
_textController.addListener(_handleTextChanged);
}
@override
void dispose() {
_adManager.dispose();
WidgetsBinding.instance.removeObserver(this);
_textController.removeListener(_handleTextChanged);
_textController.dispose();
_textFocusNode.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!mounted) {
return;
}
if (state == AppLifecycleState.paused) {
final textTabs = context.read<TextTabsController>();
textTabs.updateActiveText(_textController.text);
textTabs.persistAll();
context.read<SpeechController>().stop();
}
}
void _handleTextChanged() {
if (_applyingExternalText) {
return;
}
context.read<TextTabsController>().updateActiveText(_textController.text);
}
void _applyExternalText(String text) {
_applyingExternalText = true;
_textController.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
_applyingExternalText = false;
}
void _onTabSelected(int index) {
final textTabs = context.read<TextTabsController>();
final updatedText = textTabs.switchTo(
index,
currentText: _textController.text,
);
_applyExternalText(updatedText);
}
Future<void> _onPlay() async {
_textFocusNode.unfocus();
await context.read<SpeechController>().speak(_textController.text);
}
Future<void> _onStop() async {
await context.read<SpeechController>().stop();
}
void _openSettings() {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SettingsPage()));
}
void _showSpeechEvent(SpeechEvent event, AppLocalizations l10n) {
if (!mounted) {
return;
}
String message;
switch (event.type) {
case SpeechEventType.beginSynthesis:
message = l10n.ttsBeginSynthesis;
break;
case SpeechEventType.audioAvailable:
message = l10n.ttsAudioAvailable;
break;
case SpeechEventType.start:
message = l10n.ttsStart;
break;
case SpeechEventType.done:
message = l10n.ttsDone;
break;
case SpeechEventType.stop:
message = l10n.ttsStop;
break;
case SpeechEventType.error:
message = l10n.ttsError;
if (event.errorMessage != null && event.errorMessage!.isNotEmpty) {
message = '$message: ${event.errorMessage}';
}
break;
}
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final textTabs = context.watch<TextTabsController>();
final speechController = context.watch<SpeechController>();
final activeIndex = textTabs.activeIndex;
if (_lastActiveTab != activeIndex) {
_applyExternalText(textTabs.activeText);
_lastActiveTab = activeIndex;
}
if (_lastSpeechEventToken != speechController.eventToken &&
speechController.lastEvent != null) {
_lastSpeechEventToken = speechController.eventToken;
WidgetsBinding.instance.addPostFrameCallback((_) {
_showSpeechEvent(speechController.lastEvent!, l10n);
});
}
final tabLabels = [
l10n.tab1,
l10n.tab2,
l10n.tab3,
l10n.tab4,
l10n.tab5,
l10n.tab6,
l10n.tab7,
l10n.tab8,
l10n.tab9,
];
final isSpeaking = speechController.isSpeaking;
_themeLight = Theme.of(context).brightness == Brightness.light;
return Scaffold(
backgroundColor: _themeLight ? Colors.blueGrey[100] : Colors.blueGrey[900],
body: SafeArea(
child: GestureDetector(
onTap: () => _textFocusNode.unfocus(),
behavior: HitTestBehavior.opaque,
child: Column(
children: [
_TabSelector(
labels: tabLabels,
activeIndex: activeIndex,
onSelected: _onTabSelected,
onSettings: _openSettings,
settingsTooltip: l10n.setting,
themeLight: _themeLight,
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.only(left: 8, right: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildCardTextField(),
_buildCardVoice(speechController),
_buildCardVocalization(isSpeaking),
const SizedBox(height: 150),
],
),
),
),
],
),
),
),
bottomNavigationBar: AdBannerWidget(adManager: _adManager),
);
}
Widget _buildVoice(SpeechController speechController) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final voiceOptions = speechController.voices;
final selectedVoiceId = speechController.selectedVoice?.id;
return Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.voice,
style: theme.textTheme.titleMedium?.copyWith(
color: _themeLight ? Colors.grey[800] : Colors.grey[400],
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: InputDecorator(
decoration: InputDecoration(
border: UnderlineInputBorder(
borderSide: BorderSide(color: theme.colorScheme.outlineVariant),
),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: theme.colorScheme.outlineVariant),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: voiceOptions.isEmpty ? null : selectedVoiceId,
hint: Text(l10n.voice),
items: voiceOptions
.map(
(voice) => DropdownMenuItem<String>(
value: voice.id,
child: Text(
voice.displayLabel,
style: TextStyle(color: _themeLight ? Colors.grey[900] : Colors.grey[400]),
),
),
)
.toList(),
onChanged: voiceOptions.isEmpty
? null
: (value) =>
context.read<SpeechController>().selectVoice(value),
dropdownColor: _themeLight ? Colors.grey[200] : Colors.grey[800],
),
),
),
),
],
);
}
Widget _buildCardTextField() {
return Card(
color: _themeLight ? Colors.blueGrey[200] : Colors.blueGrey[800],
elevation: 0,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _textController,
focusNode: _textFocusNode,
minLines: 4,
maxLines: null,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.newline,
style: TextStyle(color: _themeLight ? Colors.black : Colors.white),
),
],
),
),
);
}
Widget _buildCardVoice(SpeechController speechController) {
final l10n = AppLocalizations.of(context);
return Card(
color: _themeLight ? Colors.blueGrey[200] : Colors.blueGrey[800],
elevation: 0,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
child: Padding(
padding: EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildVoice(speechController),
const SizedBox(height: 16),
_SliderRow(
label: l10n.speed,
valueLabel:
'${(speechController.speed / SpeechController.baseRate * 100).round()}%',
value:
speechController.speed /
SpeechController.baseRate,
min:
SpeechController.minRate /
SpeechController.baseRate,
max:
SpeechController.maxRate /
SpeechController.baseRate,
onChanged: (normalized) =>
context.read<SpeechController>().setSpeed(
normalized * SpeechController.baseRate,
),
themeLight: _themeLight,
),
const SizedBox(height: 12),
_SliderRow(
label: l10n.pitch,
valueLabel:
'${(speechController.pitch * 100).round()}%',
value: speechController.pitch,
min: SpeechController.minPitch,
max: SpeechController.maxPitch,
onChanged: context
.read<SpeechController>()
.setPitch,
themeLight: _themeLight,
),
],
),
),
);
}
Widget _buildCardVocalization(bool isSpeaking) {
final l10n = AppLocalizations.of(context);
return Card(
color: _themeLight ? Colors.blueGrey[200] : Colors.blueGrey[800],
elevation: 0,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: FilledButton(
onPressed: isSpeaking ? null : _onPlay,
style: ButtonStyle(
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
child: Text(l10n.play),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: isSpeaking ? _onStop : null,
style: ButtonStyle(
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
child: Text(l10n.stop),
),
),
],
),
),
);
}
}
class _TabSelector extends StatelessWidget {
const _TabSelector({
required this.labels,
required this.activeIndex,
required this.onSelected,
required this.onSettings,
required this.settingsTooltip,
required this.themeLight,
});
final List<String> labels;
final int activeIndex;
final ValueChanged<int> onSelected;
final VoidCallback onSettings;
final String settingsTooltip;
final bool themeLight;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final baseStyle = ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
elevation: 0,
);
final backColor = themeLight ? Colors.blueGrey[200] : Colors.blueGrey[800];
Widget buildTabButton(int index) {
final selected = index == activeIndex;
return Expanded(
child: Padding(
padding: const EdgeInsets.all(2),
child: ElevatedButton(
onPressed: () => onSelected(index),
style: baseStyle.copyWith(
backgroundColor: WidgetStatePropertyAll(
selected ? colorScheme.primary : backColor,
),
foregroundColor: WidgetStatePropertyAll(
selected ? Colors.white : Colors.grey[500],
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
),
),
child: Text(labels[index]),
),
),
);
}
Widget buildSettingsButton() {
return Expanded(
child: Padding(
padding: const EdgeInsets.all(2),
child: Tooltip(
message: settingsTooltip,
child: ElevatedButton(
onPressed: onSettings,
style: baseStyle.copyWith(
backgroundColor: WidgetStatePropertyAll(backColor),
foregroundColor: WidgetStatePropertyAll(Colors.grey[500]),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
),
child: const Icon(Icons.settings),
),
),
),
);
}
return Container(
decoration: BoxDecoration(color: Colors.transparent),
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(children: [for (var i = 0; i < 5; i++) buildTabButton(i)]),
Row(
children: [
for (var i = 5; i < 9; i++) buildTabButton(i),
buildSettingsButton(),
],
),
],
),
);
}
}
class _SliderRow extends StatelessWidget {
const _SliderRow({
required this.label,
required this.valueLabel,
required this.value,
required this.min,
required this.max,
required this.onChanged,
required this.themeLight,
});
final String label;
final String valueLabel;
final double value;
final double min;
final double max;
final ValueChanged<double> onChanged;
final bool themeLight;
@override
Widget build(BuildContext context) {
final clampedValue = value.clamp(min, max).toDouble();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: themeLight ? Colors.grey[800] : Colors.grey[400]),
),
),
Text(
valueLabel,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: themeLight ? Colors.grey[800] : Colors.grey[400]),
),
],
),
Slider(
min: min,
max: max,
divisions: ((max - min) / 0.1).round(),
value: clampedValue,
onChanged: onChanged,
),
],
);
}
}
lib/main.dart
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_localizations/flutter_localizations.dart";
import "package:provider/provider.dart";
import "package:shared_preferences/shared_preferences.dart";
import "l10n/app_localizations.dart";
import "package:readaloud/home_page.dart";
import "package:readaloud/speech_controller.dart";
import "package:readaloud/text_tabs_controller.dart";
import "package:readaloud/app_settings.dart";
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final settings = AppSettings(prefs);
final textTabs = TextTabsController(prefs);
final speechController = SpeechController(prefs);
await Future.wait([
settings.load(),
textTabs.load(),
speechController.init(),
]);
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider<AppSettings>.value(value: settings),
ChangeNotifierProvider<TextTabsController>.value(value: textTabs),
ChangeNotifierProvider<SpeechController>.value(value: speechController),
],
child: const ReadAloudApp(),
),
);
}
class ReadAloudApp extends StatelessWidget {
const ReadAloudApp({super.key});
@override
Widget build(BuildContext context) {
final settings = context.watch<AppSettings>();
return MaterialApp(
debugShowCheckedModeBanner: false,
onGenerateTitle: (context) => AppLocalizations.of(context).appName,
themeMode: settings.themeMode,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
useMaterial3: true,
),
locale: settings.locale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: const HomePage(),
);
}
}
lib/settings_page.dart
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "l10n/app_localizations.dart";
import "package:readaloud/ad_banner_widget.dart";
import "package:readaloud/ad_manager.dart";
import "package:readaloud/app_settings.dart";
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
static const routeName = "settings";
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
late AdManager _adManager;
late ThemeMode _themeMode;
String? _languageCode;
final Map<String, String> _languageOptions = const {
"en": "English",
"bg": "Bulgarian",
"cs": "Čeština",
"da": "Dansk",
"de": "Deutsch",
"el": "Ελληνικά",
"es": "Español",
"et": "Eesti",
"fi": "Suomi",
"fr": "Français",
"hu": "Magyar",
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"lt": "Lietuvių",
"lv": "Latviešu",
"nl": "Nederlands",
"pl": "Polski",
"pt": "Português",
"ro": "Română",
"ru": "Русский",
"sk": "Slovenčina",
"sv": "Svenska",
"th": "ไทย",
"zh": "中文",
};
@override
void initState() {
super.initState();
_adManager = AdManager();
final settings = context.read<AppSettings>();
_themeMode = settings.themeMode;
_languageCode = settings.locale?.languageCode;
}
@override
void dispose() {
_adManager.dispose();
super.dispose();
}
void _apply() {
final settings = context.read<AppSettings>();
settings.setThemeMode(_themeMode);
settings.setLocaleCode(_languageCode);
if (mounted) {
Navigator.of(context).pop();
}
}
void _cancel() {
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
leading: IconButton(icon: const Icon(Icons.close), onPressed: _cancel),
actions: [
IconButton(icon: const Icon(Icons.check), onPressed: _apply),
const SizedBox(width: 24),
],
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
_buildTheme(),
_buildLanguage(),
const Divider(height: 32),
_buildUsage(),
const SizedBox(height: 100),
],
),
),
],
),
),
bottomNavigationBar: AdBannerWidget(adManager: _adManager),
);
}
Widget _buildTheme() {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return ListTile(
title: Text(l10n.theme, style: theme.textTheme.titleMedium),
trailing: DropdownButton<ThemeMode>(
value: _themeMode,
items: [
DropdownMenuItem(
value: ThemeMode.system,
child: Text(l10n.systemDefault),
),
DropdownMenuItem(
value: ThemeMode.light,
child: Text(l10n.lightTheme),
),
DropdownMenuItem(
value: ThemeMode.dark,
child: Text(l10n.darkTheme),
),
],
onChanged: (value) {
if (value != null) {
setState(() => _themeMode = value);
}
},
),
);
}
Widget _buildLanguage() {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return ListTile(
title: Text(l10n.language, style: theme.textTheme.titleMedium),
trailing: DropdownButton<String?>(
value: _languageCode,
hint: Text(l10n.systemDefault),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text(l10n.systemDefault),
),
..._languageOptions.entries.map((entry) => DropdownMenuItem<String?>(
value: entry.key,
child: Text(entry.value),
)),
],
onChanged: (value) {
setState(() {
_languageCode = value;
});
},
),
);
}
Widget _buildUsage() {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Column(children: [
ListTile(
title: Text(l10n.usage, style: theme.textTheme.titleMedium),
subtitle: Text(l10n.usage1),
),
ListTile(
title: Text(l10n.note, style: theme.textTheme.titleMedium),
subtitle: Text(l10n.note1),
),
]);
}
}
lib/speech_controller.dart
import "package:flutter/foundation.dart";
import "package:flutter_tts/flutter_tts.dart";
import "package:shared_preferences/shared_preferences.dart";
enum SpeechEventType {
beginSynthesis,
audioAvailable,
start,
done,
stop,
error,
}
class SpeechEvent {
const SpeechEvent(this.type, {this.errorMessage});
final SpeechEventType type;
final String? errorMessage;
String get localizationKey {
switch (type) {
case SpeechEventType.beginSynthesis:
return "ttsBeginSynthesis";
case SpeechEventType.audioAvailable:
return "ttsAudioAvailable";
case SpeechEventType.start:
return "ttsStart";
case SpeechEventType.done:
return "ttsDone";
case SpeechEventType.stop:
return "ttsStop";
case SpeechEventType.error:
return "ttsError";
}
}
}
class VoiceOption {
const VoiceOption({required this.name, required this.locale, this.gender});
final String name;
final String locale;
final String? gender;
String get id => "$locale::$name";
String get displayLabel => "$locale $name";
Map<String, String> toMap() => {"name": name, "locale": locale};
}
class SpeechController extends ChangeNotifier {
SpeechController(this._prefs);
static const _voicePreferenceKey = "speechVoice";
static const double baseRate = 0.5;
static const double minRate = baseRate * 0.2;
static const double maxRate = baseRate * 3.0;
static const double minPitch = 0.2;
static const double maxPitch = 3.0;
final SharedPreferences _prefs;
final FlutterTts _tts = FlutterTts();
final List<VoiceOption> _voices = <VoiceOption>[];
VoiceOption? _selectedVoice;
double _speed = baseRate;
double _pitch = 1.0;
bool _isSpeaking = false;
bool _initialized = false;
SpeechEvent? _lastEvent;
int _eventToken = 0;
List<VoiceOption> get voices => List.unmodifiable(_voices);
VoiceOption? get selectedVoice => _selectedVoice;
double get speed => _speed;
double get pitch => _pitch;
bool get isSpeaking => _isSpeaking;
bool get isInitialized => _initialized;
SpeechEvent? get lastEvent => _lastEvent;
int get eventToken => _eventToken;
Future<void> init() async {
await _tts.awaitSpeakCompletion(true);
_tts.setStartHandler(() {
_isSpeaking = true;
_emitEvent(const SpeechEvent(SpeechEventType.start));
});
_tts.setCompletionHandler(() {
_isSpeaking = false;
_emitEvent(const SpeechEvent(SpeechEventType.done));
});
_tts.setCancelHandler(() {
_isSpeaking = false;
_emitEvent(const SpeechEvent(SpeechEventType.stop));
});
_tts.setErrorHandler((message) {
_isSpeaking = false;
final errorText = message?.toString();
_emitEvent(SpeechEvent(SpeechEventType.error, errorMessage: errorText));
});
await _loadVoices();
_initialized = true;
notifyListeners();
}
Future<void> _loadVoices() async {
dynamic result;
try {
result = await _tts.getVoices;
} catch (error, stackTrace) {
if (kDebugMode) {
// ignore: avoid_print
print("Failed to load voices: $error\n$stackTrace");
}
result = null;
}
_voices.clear();
if (result is List) {
for (final item in result) {
if (item is Map) {
final name = item["name"]?.toString();
final locale = item["locale"]?.toString();
if (name == null || locale == null) {
continue;
}
_voices.add(
VoiceOption(
name: name,
locale: locale,
gender: item["gender"]?.toString(),
),
);
}
}
}
_voices.sort((a, b) => a.displayLabel.compareTo(b.displayLabel));
final savedVoiceId = _prefs.getString(_voicePreferenceKey);
VoiceOption? fallback = _voices.isNotEmpty ? _voices.first : null;
if (savedVoiceId != null) {
_selectedVoice = _findVoiceById(savedVoiceId) ?? fallback;
} else {
_selectedVoice = fallback;
}
if (_selectedVoice != null) {
await _tts.setVoice(_selectedVoice!.toMap());
}
}
VoiceOption? _findVoiceById(String id) {
for (final voice in _voices) {
if (voice.id == id) {
return voice;
}
}
return null;
}
Future<void> speak(String text) async {
final trimmed = text.trim();
if (trimmed.isEmpty) {
return;
}
final clampedRate = _speed.clamp(minRate, maxRate).toDouble();
final clampedPitch = _pitch.clamp(minPitch, maxPitch).toDouble();
await _tts.setSpeechRate(clampedRate);
await _tts.setPitch(clampedPitch);
if (_selectedVoice != null) {
await _tts.setVoice(_selectedVoice!.toMap());
}
_emitEvent(const SpeechEvent(SpeechEventType.beginSynthesis));
final result = await _tts.speak(trimmed);
if (result == 1) {
_emitEvent(const SpeechEvent(SpeechEventType.audioAvailable));
}
}
Future<void> stop() async {
await _tts.stop();
_isSpeaking = false;
}
void setSpeed(double value) {
final clamped = value.clamp(minRate, maxRate).toDouble();
if (_speed == clamped) {
return;
}
_speed = clamped;
_tts.setSpeechRate(_speed);
notifyListeners();
}
void setPitch(double value) {
final clamped = value.clamp(minPitch, maxPitch).toDouble();
if (_pitch == clamped) {
return;
}
_pitch = clamped;
_tts.setPitch(_pitch);
notifyListeners();
}
Future<void> selectVoice(String? voiceId) async {
if (voiceId == null) {
return;
}
final voice = _findVoiceById(voiceId);
if (voice == null || _selectedVoice == voice) {
return;
}
_selectedVoice = voice;
await _tts.setVoice(voice.toMap());
await _prefs.setString(_voicePreferenceKey, voice.id);
notifyListeners();
}
void _emitEvent(SpeechEvent event) {
_lastEvent = event;
_eventToken++;
notifyListeners();
}
@override
void dispose() {
_tts.stop();
super.dispose();
}
}
lib/text_tabs_controller.dart
import "package:flutter/foundation.dart";
import "package:shared_preferences/shared_preferences.dart";
class TextTabsController extends ChangeNotifier {
TextTabsController(this._prefs);
static const int tabCount = 9;
static const _preferencesKeys = <String>[
"editText1",
"editText2",
"editText3",
"editText4",
"editText5",
"editText6",
"editText7",
"editText8",
"editText9",
];
final SharedPreferences _prefs;
final List<String> _texts = List.filled(tabCount, "", growable: false);
int _activeIndex = 0;
int get activeIndex => _activeIndex;
List<String> get texts => List.unmodifiable(_texts);
String get activeText => _texts[_activeIndex];
Future<void> load() async {
for (var i = 0; i < tabCount; i++) {
_texts[i] = _prefs.getString(_preferencesKeys[i]) ?? "";
}
notifyListeners();
}
void updateActiveText(String text, {bool persist = true}) {
if (_texts[_activeIndex] == text) {
return;
}
_texts[_activeIndex] = text;
if (persist) {
_prefs.setString(_preferencesKeys[_activeIndex], text);
}
}
String switchTo(int index, {required String currentText}) {
if (index < 0 || index >= tabCount) {
throw RangeError.range(index, 0, tabCount - 1, "index");
}
if (index == _activeIndex) {
updateActiveText(currentText);
return _texts[_activeIndex];
}
updateActiveText(currentText);
_activeIndex = index;
notifyListeners();
return _texts[_activeIndex];
}
String textFor(int index) {
if (index < 0 || index >= tabCount) {
throw RangeError.range(index, 0, tabCount - 1, "index");
}
return _texts[index];
}
Future<void> persistAll() async {
for (var i = 0; i < tabCount; i++) {
await _prefs.setString(_preferencesKeys[i], _texts[i]);
}
}
}