ソースコード source code

下記アプリの主要なソースコードを公開しています。アプリ開発の参考になれば幸いです。

画像等が別途必要ですので下記情報のみでアプリが完成するものではありません。 アプリは少しずつ機能拡張していますのでストア公開されているアプリと内容が異なる場合があります。 コードはコピーして自由にお使いいただけます。ただし著作権は放棄しておりませんので全部の再掲載はご遠慮ください。部分的に再掲載したり、改変して再掲載するのは構いません。 自身のアプリ作成の参考として個人使用・商用問わず自由にお使いいただけます。 コード記述のお手本を示すものではありません。ミニアプリですので変数名などさほど気遣いしていない部分も有りますし間違いも有るかと思いますので参考程度にお考え下さい。 他の賢者の皆様が公開されているコードを参考にした箇所も含まれます。Flutter開発の熟練者が書いたコードではありません。 エンジニア向け技術情報共有サービスではありませんので説明は省いています。 GitHubなどへの公開は予定しておりません。

下記コードの最終ビルド日: 2025-09-25

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]);
    }
  }
}