ソースコード source code

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

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

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

pubspec.yaml

name: numberroulette
description: "numberroulette"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2.2.6+33

environment:
  sdk: ">=3.3.0 <4.0.0"

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  shared_preferences: ^2.5.2
  flutter_localizations:
    sdk: flutter
  intl: ^0.20.2               #flutter gen-l10n
  google_mobile_ads: ^6.0.0
  flutter_tts: ^4.0.2
  equatable: ^2.0.7

dev_dependencies:
  flutter_test:
    sdk: flutter

  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

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^5.0.0

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

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:
  generate: true

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  assets:
    - assets/image/

  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/to/resolution-aware-images

  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/to/asset-from-package

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/to/font-from-package

lib/ad_manager.dart

import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

class AdManager {
  // テストID
  // static const String _androidAdUnitId = "ca-app-pub-3940256099942544/6300978111";
  // static const String _iosAdUnitId = "ca-app-pub-3940256099942544/2934735716";

  // 本番ID
  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;
  bool _isBannerAdLoaded = false;
  int _lastWidthPx = 0;
  VoidCallback? _onLoadedCb;
  Timer? _retryTimer;
  int _retryAttempt = 0;

  bool get isBannerAdLoaded => _isBannerAdLoaded;
  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();
    _isBannerAdLoaded = false;

    AnchoredAdaptiveBannerAdSize? adaptiveSize;
    try {
      adaptiveSize = await AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(widthPx);
    } catch (_) {
      adaptiveSize = null;
    }
    final AdSize size = adaptiveSize ?? AdSize.fullBanner; // prefer larger fallback

    _bannerAd = BannerAd(
      adUnitId: _adUnitId,
      request: const AdRequest(),
      size: size,
      listener: BannerAdListener(
        onAdLoaded: (ad) {
          _retryTimer?.cancel();
          _retryAttempt = 0;
          _isBannerAdLoaded = true;
          final cb = _onLoadedCb;
          if (cb != null) cb();
        },
        onAdFailedToLoad: (ad, err) {
          ad.dispose();
          // Retry with backoff to mitigate transient no-fill/network issues
          _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/main.dart

import 'dart:math';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_tts/flutter_tts.dart';

import 'package:numberroulette/models.dart';
import 'package:numberroulette/ad_manager.dart';
import 'package:numberroulette/setting_screen.dart';
import 'package:numberroulette/l10n/gen/app_localizations.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  MobileAds.instance.initialize();
  runApp(const MyApp());
}

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

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  ThemeMode _themeMode = ThemeMode.light;
  Locale? _locale;

  @override
  void initState() {
    super.initState();
    _loadThemeAndLocale();
  }

  Future<void> _loadThemeAndLocale() async {
    final prefs = await SharedPreferences.getInstance();
    final themeNumber = prefs.getInt('themeNumber') ?? 0;
    final localeLanguage = prefs.getString('localeLanguage') ?? '';
    setState(() {
      _themeMode = ThemeMode.values[min(themeNumber, ThemeMode.values.length - 1)];
      _locale = localeLanguage.isNotEmpty ? _localeFromTag(localeLanguage) : null;
    });
  }

  Future<void> _setTheme(int themeNumber) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt('themeNumber', themeNumber);
    setState(() {
      _themeMode = ThemeMode.values[min(themeNumber, ThemeMode.values.length - 1)];
    });
  }

  Future<void> _setLocale(String? localeTag) async {
    final prefs = await SharedPreferences.getInstance();
    if (localeTag != null && localeTag.isNotEmpty) {
      await prefs.setString('localeLanguage', localeTag);
      setState(() => _locale = _localeFromTag(localeTag));
    } else {
      await prefs.remove('localeLanguage');
      setState(() => _locale = null);
    }
  }

  Locale _localeFromTag(String tag) {
    final parts = tag.split('-');
    final lang = parts.isNotEmpty ? parts[0] : 'en';
    String? script;
    String? country;
    if (parts.length >= 2) {
      final p1 = parts[1];
      if (p1.length == 4) {
        script = p1;
      } else {
        country = p1;
      }
    }
    if (parts.length >= 3) {
      final p2 = parts[2];
      if (p2.length == 4) {
        script = p2;
      } else {
        country = p2;
      }
    }
    return Locale.fromSubtags(languageCode: lang, scriptCode: script, countryCode: country);
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Number Roulette',
      themeMode: _themeMode,
      theme: ThemeData(
        primarySwatch: Colors.blueGrey,
        brightness: Brightness.light,
        scaffoldBackgroundColor: Colors.white,
        appBarTheme: const AppBarTheme(
          backgroundColor: Color(0xFF9eabfa),
          foregroundColor: Colors.white,
        ),
        textTheme: const TextTheme(
          bodyLarge: TextStyle(color: Colors.black),
          bodyMedium: TextStyle(color: Colors.black),
          bodySmall: TextStyle(color: Colors.black),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(
            backgroundColor: Colors.black,
            foregroundColor: Colors.white,
            minimumSize: const Size.fromHeight(34),
            shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
          ),
        ),
      ),
      darkTheme: ThemeData(
        primarySwatch: Colors.blueGrey,
        brightness: Brightness.dark,
        scaffoldBackgroundColor: Colors.black,
        appBarTheme: const AppBarTheme(
          backgroundColor: Color(0xff333333),
          foregroundColor: Colors.white,
        ),
        textTheme: const TextTheme(
          bodyLarge: TextStyle(color: Colors.white),
          bodyMedium: TextStyle(color: Colors.white),
          bodySmall: TextStyle(color: Colors.white),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(
            backgroundColor: Colors.black,
            foregroundColor: Colors.white,
            minimumSize: const Size.fromHeight(34),
            shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
          ),
        ),
      ),
      locale: _locale,
      supportedLocales: AppLocalizations.supportedLocales,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      home: MyHomePage(
        setTheme: _setTheme,
        setLocale: _setLocale,
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  final Function(int) setTheme;
  final Function(String?) setLocale;

  const MyHomePage({super.key, required this.setTheme, required this.setLocale});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  late SharedPreferences _prefs;
  final FlutterTts flutterTts = FlutterTts();
  late NumberSettings _settings;
  bool _isLoading = true;
  late AnimationController _controller;
  late Animation<double> _animation;
  int? _currentNumber;
  String? _resultText;
  Color? _currentBackgroundColor;
  final _random = Random();

  List<Map<String, String>> _availableTtsVoices = [];
  late AdManager _adManager;
  bool _isAdLoaded = false;
  int? _lastBannerWidthDp;

  // Colors (from NumberRoulette original)
  final List<Color> _colorLight = const [
    Color(0xFFEF9A9A), Color(0xFFF48FB1), Color(0xFFCE93D8), Color(0xFFB39DDB),
    Color(0xFF9FA8DA), Color(0xFF90CAF9), Color(0xFF81D4FA), Color(0xFF80DEEA),
    Color(0xFF80CBC4), Color(0xFFA5D6A7), Color(0xFFC5E1A5), Color(0xFFE6EE9C),
    Color(0xFFFFF590), Color(0xFFFFE082), Color(0xFFFFCC80), Color(0xFFFFAB91),
    Color(0xFFE57373), Color(0xFFF06292), Color(0xFFBA68C8), Color(0xFF9575CD),
    Color(0xFF7986CB), Color(0xFF64B5F6), Color(0xFF4FC3F7), Color(0xFF4DD0E1),
    Color(0xFF4DB6AC), Color(0xFF81C784), Color(0xFFAED581), Color(0xFFDCE775),
    Color(0xFFFFF176), Color(0xFFFFD54F), Color(0xFFFFB74D), Color(0xFFFF8A65),
  ];
  final List<Color> _colorDark = const [
    Color(0xFFEF5350), Color(0xFFEC407A), Color(0xFFAB47BC), Color(0xFF7E57C2),
    Color(0xFF5C6BC0), Color(0xFF42A5F5), Color(0xFF29B6FC), Color(0xFF26C6DA),
    Color(0xFF26A69A), Color(0xFF66BB6A), Color(0xFF9CCC65), Color(0xFFD4E157),
    Color(0xFFFFEE58), Color(0xFFFFCA28), Color(0xFFFFA726), Color(0xFFFF7043),
    Color(0xFFF44336), Color(0xFFE91E63), Color(0xFF9C27B0), Color(0xFF673AB7),
    Color(0xFF3F51B5), Color(0xFF2196F3), Color(0xFF03A9F4), Color(0xFF00BCD4),
    Color(0xFF009688), Color(0xFF4CAF50), Color(0xFF8BC34A), Color(0xFFCDDC39),
    Color(0xFFFFEB3B), Color(0xFFFFC107), Color(0xFFFF9800), Color(0xFFFF5722),
  ];
  final Color _fixedBgColor = const Color(0xFFaaaaaa);

  List<int> _orderedNumbers = [];

  @override
  void initState() {
    super.initState();
    _adManager = AdManager();
    _initAsync();
    _controller = AnimationController(vsync: this, duration: const Duration(seconds: 10))
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          _determineWinner();
        }
      });
    _animation = Tween<double>(begin: 0, end: 360 * 20).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.linear,
    ));
  }

  Future<void> _initAsync() async {
    _prefs = await SharedPreferences.getInstance();
    _settings = _loadSettings();
    _rebuildOrderedNumbers();
    await _initTts();
    _updateVisualForAngle(0);
    setState(() => _isLoading = false);
  }

  void _updateBannerForWidth(int widthDp) {
    if (widthDp <= 0) return;
    if (_lastBannerWidthDp == widthDp && _isAdLoaded && _adManager.bannerAd != null &&
        _adManager.bannerAd!.size.width == widthDp) {
      return;
    }
    _lastBannerWidthDp = widthDp;
    _adManager.loadAdaptiveBannerAd(widthDp, () {
      if (mounted) setState(() => _isAdLoaded = true);
    });
  }

  NumberSettings _loadSettings() {
    return NumberSettings(
      minNumber: _prefs.getInt('minNumber') ?? 0,
      maxNumber: _prefs.getInt('maxNumber') ?? 5,
      positionReverse: (_prefs.getInt('positionReverse') ?? 0) == 1,
      positionRandom: (_prefs.getInt('positionRandom') ?? 0) == 1,
      fixBackground: (_prefs.getInt('fixBackground') ?? 0) == 1,
      shortRotation: (_prefs.getInt('shortRotation') ?? 0) == 1,
      speechNumber: (_prefs.getInt('speechNumber') ?? 1) == 1,
      speechVoice: _prefs.getString('speechVoice') ?? '',
      speechLocale: _prefs.getString('speechLocale') ?? '',
      maxSpeedDuration: _prefs.getDouble('maxSpeedDuration') ?? 5.0,
      themeNumber: _prefs.getInt('themeNumber') ?? 0,
      localeLanguage: _prefs.getString('localeLanguage') ?? '',
      resultTextScale: _prefs.getDouble('resultTextScale') ?? 1.0,
      rouletteTextScale: _prefs.getDouble('rouletteTextScale') ?? 1.0,
    );
  }

  Future<void> _saveSettings() async {
    await _prefs.setInt('minNumber', _settings.minNumber);
    await _prefs.setInt('maxNumber', _settings.maxNumber);
    await _prefs.setInt('positionReverse', _settings.positionReverse ? 1 : 0);
    await _prefs.setInt('positionRandom', _settings.positionRandom ? 1 : 0);
    await _prefs.setInt('fixBackground', _settings.fixBackground ? 1 : 0);
    await _prefs.setInt('shortRotation', _settings.shortRotation ? 1 : 0);
    await _prefs.setInt('speechNumber', _settings.speechNumber ? 1 : 0);
    await _prefs.setString('speechVoice', _settings.speechVoice);
    await _prefs.setString('speechLocale', _settings.speechLocale);
    await _prefs.setDouble('maxSpeedDuration', _settings.maxSpeedDuration);
    await _prefs.setInt('themeNumber', _settings.themeNumber);
    await _prefs.setString('localeLanguage', _settings.localeLanguage);
    await _prefs.setDouble('resultTextScale', _settings.resultTextScale);
    await _prefs.setDouble('rouletteTextScale', _settings.rouletteTextScale);
  }

  void _rebuildOrderedNumbers() {
    final minN = _settings.minNumber;
    final maxN = _settings.maxNumber;
    var list = List<int>.generate(maxN - minN + 1, (i) => minN + i);
    if (_settings.positionRandom) {
      list.shuffle(_random);
    } else if (_settings.positionReverse) {
      list = list.reversed.toList();
    }
    _orderedNumbers = list;
  }

  Future<void> _initTts() async {
    _availableTtsVoices = (await flutterTts.getVoices as List<dynamic>)
        .map((e) => {'name': e['name'].toString(), 'locale': e['locale'].toString().replaceAll('_', '-')})
        .toList();

    Map<String, String>? selectedVoice;
    if (_settings.speechVoice.isNotEmpty && _settings.speechLocale.isNotEmpty) {
      final normalizedSettingsLocale = _settings.speechLocale.replaceAll('_', '-');
      try {
        selectedVoice = _availableTtsVoices.firstWhere(
          (voice) => voice['name'] == _settings.speechVoice && voice['locale'] == normalizedSettingsLocale,
        );
      } catch (_) {}
    }

    if (selectedVoice != null) {
      await flutterTts.setVoice(selectedVoice);
    } else {
      await flutterTts.setLanguage('en-US');
    }
    await flutterTts.setSpeechRate(0.5);
    await flutterTts.setVolume(1.0);
    await flutterTts.setPitch(1.0);
  }

  @override
  void dispose() {
    _controller.dispose();
    flutterTts.stop();
    _adManager.dispose();
    super.dispose();
  }

  void _onClickStart() {
    setState(() {
      _resultText = null;
    });

    final double scale = _settings.shortRotation ? 0.1 : 1.0;
    final double easeInDuration = 1.0 * scale; // seconds
    final double linearDuration = _settings.maxSpeedDuration * scale; // seconds
    final double easeOutDuration = 8.0 * scale; // seconds
    final double totalDuration = easeInDuration + linearDuration + easeOutDuration;

    _controller.duration = Duration(milliseconds: (totalDuration * 1000).round());

    const double baseEaseIn = 1.0;
    const double baseLinearDuration = 5.0;
    const double baseEaseOut = 8.0;
    const double baseTotalDuration = baseEaseIn + baseLinearDuration + baseEaseOut;
    const double baseRotationAmount = 360 * 28;
    final double targetRotationAmount = baseRotationAmount * (totalDuration / baseTotalDuration);

    final double randomExtraRotation = 360 * (_random.nextDouble() - 0.5);

    final double beginAngle = _animation.value;
    final double endAngle = beginAngle + targetRotationAmount + randomExtraRotation;

    _animation = Tween<double>(begin: beginAngle, end: endAngle).animate(CurvedAnimation(
      parent: _controller,
      curve: ThreePhaseRouletteCurve(
        easeInDuration: easeInDuration,
        linearDuration: linearDuration,
        easeOutDuration: easeOutDuration,
      ),
    ));

    _controller.forward(from: 0.0);
  }

  void _updateCurrentNumber() {
    if (_orderedNumbers.isEmpty) return;
    final double currentAngle = _animation.value;
    // Map to 0..360 and pointer at top (270 degrees in our draw coordinates)
    final double effectiveAngle = (360 - (currentAngle % 360) + 270) % 360;

    final int count = _orderedNumbers.length;
    final double sweep = 360.0 / count;
    int index = (effectiveAngle / sweep).floor();
    if (index < 0 || index >= count) index = index % count;
    final number = _orderedNumbers[index];
    _currentNumber = number;
    final colorIdx = index % _colorLight.length;
    final Color segColor = _colorLight[colorIdx];
    if (_controller.isAnimating && _settings.fixBackground) {
      _currentBackgroundColor = _fixedBgColor;
    } else {
      _currentBackgroundColor = segColor;
    }
  }

  void _determineWinner() {
    _updateCurrentNumber();
    setState(() {
      _resultText = _currentNumber?.toString();
      if (_settings.speechNumber && _resultText != null) {
        flutterTts.speak(_resultText!);
      }
    });
  }

  void _updateVisualForAngle(double angle) {
    if (_orderedNumbers.isEmpty) return;
    final double effectiveAngle = (360 - (angle % 360) + 270) % 360;
    final int count = _orderedNumbers.length;
    final double sweep = 360.0 / count;
    int index = (effectiveAngle / sweep).floor();
    if (index < 0 || index >= count) index = index % count;
    final number = _orderedNumbers[index];
    setState(() {
      _currentNumber = number;
      final Color segColor = _colorLight[index % _colorLight.length];
      _currentBackgroundColor = segColor;
      _resultText ??= number.toString();
    });
  }

  Future<void> _onClickSetting() async {
    final updated = await Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => SettingScreen(initialSettings: _settings),
      ),
    );
    if (updated != null && mounted) {
      setState(() => _settings = updated);
      await _saveSettings();
      widget.setTheme(_settings.themeNumber);
      widget.setLocale(_settings.localeLanguage.isEmpty ? null : _settings.localeLanguage);
      _rebuildOrderedNumbers();
      await _initTts();
      _updateVisualForAngle(_animation.value);
    }
  }

  @override
  Widget build(BuildContext context) {
    final localizations = AppLocalizations.of(context);
    if (_isLoading) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }

    return AnimatedBuilder(
      animation: _controller,
      builder: (context, _) {
        _updateCurrentNumber();
        return Scaffold(
          backgroundColor: _currentBackgroundColor,
          appBar: AppBar(
            title: const Text(''),
            elevation: 0,
            actions: [
              IconButton(icon: const Icon(Icons.settings), onPressed: _onClickSetting),
              const SizedBox(width: 24),
            ],
          ),
          body: SafeArea(
            child: Stack(
              children: [
                Column(
                  children: [
                    LinearProgressIndicator(
                      value: _controller.value,
                      minHeight: 5.0,
                      backgroundColor: Colors.white.withValues(alpha: 0.3),
                      valueColor: AlwaysStoppedAnimation<Color>(Colors.white.withValues(alpha: 0.8)),
                    ),
                    const Spacer(flex: 1),
                    SizedBox(
                      // Grow the result area height with the font scale to avoid clipping
                      height: () {
                        final h = 50.0 * _settings.resultTextScale + 20.0;
                        final limit = MediaQuery.of(context).size.height * 0.6; // cap to 60% of screen
                        final clamped = h.clamp(70.0, limit);
                        return clamped;
                      }(),
                      child: Visibility(
                        visible: _controller.isAnimating || _resultText != null,
                        maintainState: true,
                        maintainAnimation: true,
                        maintainSize: true,
                        child: Padding(
                          padding: const EdgeInsets.symmetric(horizontal: 16.0),
                          child: Text(
                            _controller.isAnimating ? '${_currentNumber ?? ''}' : (_resultText ?? ''),
                            style: Theme.of(context).textTheme.headlineMedium?.copyWith(
                              color: Theme.of(context).brightness == Brightness.light ? Colors.white : Colors.black,
                              fontSize: 50.0 * _settings.resultTextScale,
                              fontWeight: FontWeight.bold,
                            ),
                            textAlign: TextAlign.center,
                          ),
                        ),
                      ),
                    ),
                    Expanded(
                      flex: 6,
                      child: Stack(
                        children: [
                          Positioned.fill(
                            child: CustomPaint(
                              painter: NumberRoulettePainter(
                                animationValue: _animation.value,
                                numbers: _orderedNumbers,
                                colorLight: _colorLight,
                                colorDark: _colorDark,
                                boardFontScale: _settings.rouletteTextScale,
                              ),
                            ),
                          ),
                          Positioned.fill(
                            child: Align(
                              alignment: Alignment.center,
                              child: AnimatedOpacity(
                                opacity: _controller.isAnimating ? 0.0 : 1.0,
                                duration: const Duration(milliseconds: 600),
                                child: GestureDetector(
                                  onTap: _onClickStart,
                                  child: Container(
                                    width: 100,
                                    height: 100,
                                    decoration: BoxDecoration(
                                      color: const Color(0xFF000000).withValues(alpha: 0.6),
                                      shape: BoxShape.circle,
                                    ),
                                    alignment: Alignment.center,
                                    child: Text(
                                      localizations.rouletteStart,
                                      style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white),
                                    ),
                                  ),
                                ),
                              ),
                            ),
                          ),
                        ],
                      ),
                    ),
                    const Spacer(flex: 2),
                  ]
                ),
                Positioned(
                  bottom: 0,
                  left: 0,
                  right: 0,
                  child: LayoutBuilder(
                    builder: (context, constraints) {
                      final int width = constraints.maxWidth.isFinite
                          ? constraints.maxWidth.truncate()
                          : MediaQuery.of(context).size.width.truncate();
                      if (width > 0) {
                        WidgetsBinding.instance.addPostFrameCallback((_) {
                          if (mounted) _updateBannerForWidth(width);
                        });
                      }
                      return Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          const SizedBox(height: 10),
                          if (_isAdLoaded && _adManager.bannerAd != null)
                            Center(
                              child: SizedBox(
                                width: _adManager.bannerAd!.size.width.toDouble(),
                                height: _adManager.bannerAd!.size.height.toDouble(),
                                child: AdWidget(ad: _adManager.bannerAd!),
                              ),
                            ),
                        ],
                      );
                    },
                  ),
                )
              ],
            ),
          ),
        );
      },
    );
  }
}

class NumberRoulettePainter extends CustomPainter {
  final double animationValue; // degrees
  final List<int> numbers;
  final List<Color> colorLight;
  final List<Color> colorDark;
  final double boardFontScale;

  NumberRoulettePainter({
    required this.animationValue,
    required this.numbers,
    required this.colorLight,
    required this.colorDark,
    this.boardFontScale = 1.0,
  });

  @override
  void paint(Canvas canvas, Size size) {
    if (numbers.isEmpty) return;
    final double centerX = size.width / 2;
    final double centerY = size.height / 2;
    final double radius = min(centerX, centerY) * 0.8;

    final Paint whitePaint = Paint()..color = Colors.white;
    // 359-degree arc to create a 1-degree gap at the top
    final double gap = pi / 180;
    canvas.drawArc(
      Rect.fromCircle(center: Offset(centerX, centerY), radius: radius + 10),
      -pi / 2 + gap / 2,
      2 * pi - gap,
      true,
      whitePaint,
    );

    double startAngle = animationValue * (pi / 180); // radians
    final int count = numbers.length;
    final double sweepAngle = (2 * pi) / count;

    for (int i = 0; i < count; i++) {
      final Paint segmentPaint = Paint()..color = colorLight[i % colorLight.length];
      canvas.drawArc(
        Rect.fromCircle(center: Offset(centerX, centerY), radius: radius),
        startAngle,
        sweepAngle,
        true,
        segmentPaint,
      );
      final Paint darkPaint = Paint()..color = colorDark[i % colorDark.length];
      canvas.drawArc(
        Rect.fromCircle(center: Offset(centerX, centerY), radius: radius / 2),
        startAngle,
        sweepAngle,
        true,
        darkPaint,
      );

      // draw number labels only if not too many
      if (count < 360) {
        final double textAngle = startAngle + sweepAngle / 2;
        final double textRadius = radius * 0.8;
        final double textX = centerX + textRadius * cos(textAngle);
        final double textY = centerY + textRadius * sin(textAngle);
        const double boardFontSize = 15.0;
        final tp = TextPainter(
          text: TextSpan(style: TextStyle(color: Colors.black, fontSize: boardFontSize * boardFontScale), text: numbers[i].toString()),
          textDirection: TextDirection.ltr,
        );
        tp.layout();
        canvas.save();
        canvas.translate(textX, textY);
        canvas.rotate(textAngle + pi / 2);
        tp.paint(canvas, Offset(-tp.width / 2, -tp.height / 2));
        canvas.restore();
      }

      startAngle += sweepAngle;
    }
  }

  @override
  bool shouldRepaint(covariant NumberRoulettePainter oldDelegate) {
    return oldDelegate.animationValue != animationValue ||
        !listEquals(oldDelegate.numbers, numbers) ||
        oldDelegate.boardFontScale != boardFontScale;
  }
}

class ThreePhaseRouletteCurve extends Curve {
  final double easeInDuration;
  final double linearDuration;
  final double easeOutDuration;

  const ThreePhaseRouletteCurve({
    required this.easeInDuration,
    required this.linearDuration,
    required this.easeOutDuration,
  });

  @override
  double transformInternal(double t) {
    final total = easeInDuration + linearDuration + easeOutDuration;
    final easeInFrac = easeInDuration / total;
    final linearFrac = linearDuration / total;

    final distEaseIn = 0.5 * easeInDuration;
    final distLinear = 1.0 * linearDuration;
    final distEaseOut = 0.5 * easeOutDuration;
    final totalDist = distEaseIn + distLinear + distEaseOut;

    if (t < easeInFrac) {
      final time = t * total;
      final distance = 0.5 * time * time / easeInDuration;
      return distance / totalDist;
    } else if (t < easeInFrac + linearFrac) {
      final time = (t - easeInFrac) * total;
      final distance = distEaseIn + time;
      return distance / totalDist;
    } else {
      final time = (t - easeInFrac - linearFrac) * total;
      final v0 = 1.0;
      final a = -v0 / easeOutDuration;
      final distance = distEaseIn + distLinear + (v0 * time + 0.5 * a * time * time);
      return distance / totalDist;
    }
  }
}

lib/models.dart

import 'package:equatable/equatable.dart';

class NumberSettings extends Equatable {
  final int minNumber;
  final int maxNumber;
  final bool positionReverse;
  final bool positionRandom;
  final bool fixBackground;
  final bool shortRotation;
  final bool speechNumber;
  final String speechVoice;
  final String speechLocale;
  final double maxSpeedDuration; // seconds for linear phase
  final int themeNumber; // ThemeMode.index
  final String localeLanguage;
  final double resultTextScale; // 0.5 .. 10.0 (50%..1000%)
  final double rouletteTextScale; // 0.5 .. 10.0 (50%..1000%)

  const NumberSettings({
    required this.minNumber,
    required this.maxNumber,
    this.positionReverse = false,
    this.positionRandom = false,
    this.fixBackground = false,
    this.shortRotation = false,
    this.speechNumber = true,
    this.speechVoice = '',
    this.speechLocale = '',
    this.maxSpeedDuration = 5.0,
    this.themeNumber = 0,
    this.localeLanguage = '',
    this.resultTextScale = 1.0,
    this.rouletteTextScale = 1.0,
  });

  NumberSettings copyWith({
    int? minNumber,
    int? maxNumber,
    bool? positionReverse,
    bool? positionRandom,
    bool? fixBackground,
    bool? shortRotation,
    bool? speechNumber,
    String? speechVoice,
    String? speechLocale,
    double? maxSpeedDuration,
    int? themeNumber,
    String? localeLanguage,
    double? resultTextScale,
    double? rouletteTextScale,
  }) {
    return NumberSettings(
      minNumber: minNumber ?? this.minNumber,
      maxNumber: maxNumber ?? this.maxNumber,
      positionReverse: positionReverse ?? this.positionReverse,
      positionRandom: positionRandom ?? this.positionRandom,
      fixBackground: fixBackground ?? this.fixBackground,
      shortRotation: shortRotation ?? this.shortRotation,
      speechNumber: speechNumber ?? this.speechNumber,
      speechVoice: speechVoice ?? this.speechVoice,
      speechLocale: speechLocale ?? this.speechLocale,
      maxSpeedDuration: maxSpeedDuration ?? this.maxSpeedDuration,
      themeNumber: themeNumber ?? this.themeNumber,
      localeLanguage: localeLanguage ?? this.localeLanguage,
      resultTextScale: resultTextScale ?? this.resultTextScale,
      rouletteTextScale: rouletteTextScale ?? this.rouletteTextScale,
    );
  }

  @override
  List<Object?> get props => [
    minNumber,
    maxNumber,
    positionReverse,
    positionRandom,
    fixBackground,
    shortRotation,
    speechNumber,
    speechVoice,
    speechLocale,
    maxSpeedDuration,
    themeNumber,
    localeLanguage,
    resultTextScale,
    rouletteTextScale,
  ];
}

lib/setting_screen.dart

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

import 'package:numberroulette/ad_manager.dart';
import 'package:numberroulette/models.dart';
import 'package:numberroulette/l10n/gen/app_localizations.dart';

class SettingScreen extends StatefulWidget {
  final NumberSettings initialSettings;
  const SettingScreen({super.key, required this.initialSettings});

  @override
  State<SettingScreen> createState() => _SettingScreenState();
}

class _SettingScreenState extends State<SettingScreen> {
  late NumberSettings _currentSettings;
  late ThemeMode _tempThemeMode;
  String? _selectedLocaleTag;

  final TextEditingController _minController = TextEditingController();
  final TextEditingController _maxController = TextEditingController();
  // Text size controls (sliders) are defined below

  List<Map<String, String>> _speechVoices = [];
  Map<String, String>? _selectedSpeechVoice;

  late AdManager _adManager;
  bool _isAdLoaded = false;
  bool _bannerRequested = false; // kept for compatibility, no longer used for loading
  int? _lastBannerWidthDp;

  // Allowed percent options for font scales
  static const List<int> _percentOptions = [
    41, 51, 64, 80, 100, 120, 144, 173, 207, 249, 299, 358, 430, 516, 619, 743, 892, 1070
  ];

  int _nearestIndexForScale(double scale) {
    final target = (scale * 100).round();
    int bestIdx = 0;
    int bestDiff = 1 << 30;
    for (int i = 0; i < _percentOptions.length; i++) {
      final d = (target - _percentOptions[i]).abs();
      if (d < bestDiff) {
        bestDiff = d;
        bestIdx = i;
      }
    }
    return bestIdx;
  }

  double _scaleForIndex(int index) {
    final i = index.clamp(0, _percentOptions.length - 1).toInt();
    return _percentOptions[i] / 100.0;
  }

  // Language options (BCP-47 tags)
  final Map<String, String> languageOptions = const {
    'en': 'English',
    'fr': 'Français',
    'it': 'Italiano',
    'de': 'Deutsch',
    'es': 'Español',
    'es-419': 'Español (Latinoamérica)',
    'id': 'Indonesia',
    'uk': 'Українська',
    'nl': 'Nederlands',
    'el': 'Ελληνικά',
    'sv': 'Svenska',
    'th': 'ไทย',
    'cs': 'Čeština',
    'da': 'Dansk',
    'tr': 'Türkçe',
    'nb': 'Norsk (Bokmål)',
    'hu': 'Magyar',
    'fi': 'Suomi',
    'bg': 'Български',
    'vi': 'Tiếng Việt',
    'pt-BR': 'Português (Brasil)',
    'pt-PT': 'Português (Portugal)',
    'pl': 'Polski',
    'ro': 'Română',
    'ru': 'Русский',
    'zh-Hans': '中文(简体)',
    'zh-Hant': '中文(繁體)',
    'ja': '日本語',
    'ko': '한국어',
    'ar': 'العربية',
  };

  

  @override
  void initState() {
    super.initState();
    _currentSettings = widget.initialSettings;
    _tempThemeMode = ThemeMode.values[min(_currentSettings.themeNumber, ThemeMode.values.length - 1)];
    _selectedLocaleTag = _currentSettings.localeLanguage.isEmpty ? null : _currentSettings.localeLanguage;

    _minController.text = _currentSettings.minNumber.toString();
    _maxController.text = _currentSettings.maxNumber.toString();
    // Text sizes are fixed; no initialization needed

    _adManager = AdManager();

    _initTts();
  }

  void _updateBannerForWidth(int widthDp) {
    if (widthDp <= 0) return;
    if (_lastBannerWidthDp == widthDp && _isAdLoaded && _adManager.bannerAd != null &&
        _adManager.bannerAd!.size.width == widthDp) {
      return;
    }
    _lastBannerWidthDp = widthDp;
    _adManager.loadAdaptiveBannerAd(widthDp, () {
      if (mounted) setState(() => _isAdLoaded = true);
    });
  }

  Future<void> _initTts() async {
    final tts = FlutterTts();
    _speechVoices = (await tts.getVoices as List<dynamic>)
        .map((e) => {'name': e['name'].toString(), 'locale': e['locale'].toString().replaceAll('_', '-')})
        .toList();
    _speechVoices.sort((a, b) => a['locale']!.compareTo(b['locale']!));

    Map<String, String>? foundVoice;
    if (_currentSettings.speechVoice.isNotEmpty && _currentSettings.speechLocale.isNotEmpty) {
      final normalizedSettingsLocale = _currentSettings.speechLocale.replaceAll('_', '-');
      try {
        foundVoice = _speechVoices.firstWhere((voice) =>
            voice['name'] == _currentSettings.speechVoice && voice['locale'] == normalizedSettingsLocale);
      } catch (_) {}
    }
    setState(() => _selectedSpeechVoice = foundVoice);
  }

  @override
  void dispose() {
    _minController.dispose();
    _maxController.dispose();
    // No text size controllers
    _adManager.dispose();
    super.dispose();
  }

  void _onApply() {
    int minNumber = int.tryParse(_minController.text.trim()) ?? 0;
    int maxNumber = int.tryParse(_maxController.text.trim()) ?? (minNumber + 1);
    if (minNumber < 0) minNumber = 0;
    if (maxNumber <= minNumber) maxNumber = minNumber + 1;
    if (maxNumber - minNumber >= 3600) maxNumber = minNumber + 3599;

    final newSettings = _currentSettings.copyWith(
      minNumber: minNumber,
      maxNumber: maxNumber,
      speechVoice: _selectedSpeechVoice?['name'] ?? '',
      speechLocale: _selectedSpeechVoice?['locale'] ?? '',
      themeNumber: _tempThemeMode.index,
      localeLanguage: _selectedLocaleTag ?? '',
    );

    Navigator.pop(context, newSettings);
  }

  void _onCancel() {
    Navigator.pop(context);
  }

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context);
    return Scaffold(
      appBar: AppBar(
        leading: IconButton(icon: const Icon(Icons.close), onPressed: _onCancel),
        title: const Text(''),
        actions: [
          IconButton(icon: const Icon(Icons.check), onPressed: _onApply),
          const SizedBox(width: 24),
        ],
      ),
      body: GestureDetector(
        onTap: () => FocusScope.of(context).unfocus(),
        child: SafeArea(
          child: Column(
            children: [
              Expanded(
                child: SingleChildScrollView(
                  padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Padding(
                        padding: const EdgeInsets.symmetric(horizontal: 8.0),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(l.numberRange, style: Theme.of(context).textTheme.titleMedium),
                            const SizedBox(height: 8),
                            Row(
                              children: [
                                Expanded(
                                  child: TextField(
                                    controller: _minController,
                                    decoration: InputDecoration(
                                      labelText: l.min,
                                      border: OutlineInputBorder(),
                                      isDense: true,
                                      contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
                                    ),
                                    keyboardType: TextInputType.number,
                                    inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                                  ),
                                ),
                                const SizedBox(width: 12),
                                Expanded(
                                  child: TextField(
                                    controller: _maxController,
                                    decoration: InputDecoration(
                                      labelText: l.max,
                                      border: OutlineInputBorder(),
                                      isDense: true,
                                      contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
                                    ),
                                    keyboardType: TextInputType.number,
                                    inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                                  ),
                                ),
                              ],
                            ),
                          ],
                        ),
                      ),
                      const Divider(height: 40, thickness: 1),
                      Padding(
                        padding: const EdgeInsets.only(right: 16.0),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Padding(
                              padding: const EdgeInsets.only(left: 16.0),
                              child: Text(l.textSizeAdjustResult),
                            ),
                            const SizedBox(height: 4),
                            Row(
                              children: [
                                Expanded(
                                  child: Slider(
                                    min: 0,
                                    max: (_percentOptions.length - 1).toDouble(),
                                    divisions: _percentOptions.length - 1,
                                    value: _nearestIndexForScale(_currentSettings.resultTextScale).toDouble(),
                                    label: '${_percentOptions[_nearestIndexForScale(_currentSettings.resultTextScale)]}%',
                                    onChanged: (v) {
                                      final idx = v.round();
                                      setState(() {
                                        _currentSettings = _currentSettings.copyWith(resultTextScale: _scaleForIndex(idx));
                                      });
                                    },
                                  ),
                                ),
                                Text(
                                  '${_percentOptions[_nearestIndexForScale(_currentSettings.resultTextScale)]}%',
                                  style: Theme.of(context).textTheme.titleMedium,
                                ),
                              ],
                            ),
                          ],
                        ),
                      ),
                      const Divider(height: 20, thickness: 0),
                      Padding(
                        padding: const EdgeInsets.only(right: 16.0),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Padding(
                              padding: const EdgeInsets.only(left: 16.0),
                              child: Text(l.textSizeAdjustRoulette),
                            ),
                            const SizedBox(height: 4),
                            Row(
                              children: [
                                Expanded(
                                  child: Slider(
                                    min: 0,
                                    max: (_percentOptions.length - 1).toDouble(),
                                    divisions: _percentOptions.length - 1,
                                    value: _nearestIndexForScale(_currentSettings.rouletteTextScale).toDouble(),
                                    label: '${_percentOptions[_nearestIndexForScale(_currentSettings.rouletteTextScale)]}%',
                                    onChanged: (v) {
                                      final idx = v.round();
                                      setState(() {
                                        _currentSettings = _currentSettings.copyWith(rouletteTextScale: _scaleForIndex(idx));
                                      });
                                    },
                                  ),
                                ),
                                Text(
                                  '${_percentOptions[_nearestIndexForScale(_currentSettings.rouletteTextScale)]}%',
                                  style: Theme.of(context).textTheme.titleMedium,
                                ),
                              ],
                            ),
                          ],
                        ),
                      ),
                      const Divider(height: 40, thickness: 1),
                      SwitchListTile(
                        contentPadding: const EdgeInsets.only(left: 16.0,right: 12.0),
                        title: Text(l.reverseOrder),
                        value: _currentSettings.positionReverse,
                        onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(positionReverse: v, positionRandom: v ? false : _currentSettings.positionRandom)),
                      ),
                      SwitchListTile(
                        contentPadding: const EdgeInsets.only(left: 16.0,right: 12.0),
                        title: Text(l.randomizeOrder),
                        value: _currentSettings.positionRandom,
                        onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(positionRandom: v, positionReverse: v ? false : _currentSettings.positionReverse)),
                      ),
                      SwitchListTile(
                        contentPadding: const EdgeInsets.only(left: 16.0,right: 12.0),
                        title: Text(l.fixBackgroundWhileSpinning),
                        value: _currentSettings.fixBackground,
                        onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(fixBackground: v)),
                      ),
                      const Divider(height: 40, thickness: 1),
                      Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Padding(
                            padding: const EdgeInsets.only(left: 16,right: 16),
                            child: Text(l.rotationTime, style: Theme.of(context).textTheme.titleMedium),
                          ),
                          const SizedBox(height: 4),
                          // Removed description text as requested
                          Row(
                            children: [
                              Expanded(
                                child: Slider(
                                  value: _currentSettings.maxSpeedDuration,
                                  min: 1.0,
                                  max: 15.0,
                                  divisions: 14,
                                  label: _currentSettings.maxSpeedDuration.toStringAsFixed(1),
                                  onChanged: (double value) {
                                    setState(() {
                                      _currentSettings = _currentSettings.copyWith(maxSpeedDuration: value);
                                    });
                                  },
                                ),
                              ),
                              Padding(
                                padding: const EdgeInsets.only(right: 16.0),
                                child: Text(
                                  _currentSettings.maxSpeedDuration.toInt().toString().padLeft(2, '0'),
                                  style: Theme.of(context).textTheme.titleMedium,
                                ),
                              )
                            ],
                          ),
                          SwitchListTile(
                            contentPadding: const EdgeInsets.only(left: 16,right: 12),
                            title: Text(l.shortenRotation),
                            value: _currentSettings.shortRotation,
                            onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(shortRotation: v)),
                          ),
                        ],
                      ),
                      const Divider(height: 40, thickness: 1),
                      SwitchListTile(
                        contentPadding: const EdgeInsets.only(left: 16.0,right: 12.0),
                        title: Text(l.speechResult),
                        value: _currentSettings.speechNumber,
                        onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(speechNumber: v)),
                      ),
                      Padding(
                        padding: const EdgeInsets.only(left: 16.0,right: 24.0),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(l.speechVoice),
                            const SizedBox(height: 4),
                            DropdownButton<Map<String, String>>(
                              value: _selectedSpeechVoice,
                              isExpanded: true,
                              items: _speechVoices.map((v) => DropdownMenuItem<Map<String, String>>(
                                value: v,
                                child: Text('${v['locale']} - ${v['name']}'),
                              )).toList(),
                              onChanged: (nv) => setState(() => _selectedSpeechVoice = nv),
                            ),
                          ],
                        ),
                      ),
                      const Divider(height: 40, thickness: 1),
                      ListTile(
                        title: Text(l.language),
                        trailing: DropdownButton<String?>(
                          value: _selectedLocaleTag,
                          hint: Text(l.systemDefault),
                          items: [
                            DropdownMenuItem<String?>(value: null, child: Text(l.systemDefault)),
                            ...languageOptions.entries.map((e) => DropdownMenuItem<String?>(value: e.key, child: Text(e.value))),
                          ],
                          onChanged: (value) {
                            setState(() {
                              _selectedLocaleTag = value;
                            });
                          },
                        ),
                      ),
                      const Divider(height: 40, thickness: 1),
                      ListTile(
                        title: Text(l.theme),
                        trailing: DropdownButton<ThemeMode>(
                          value: _tempThemeMode,
                          items: [
                            DropdownMenuItem(value: ThemeMode.system, child: Text(l.systemDefault)),
                            DropdownMenuItem(value: ThemeMode.light, child: Text(l.lightTheme)),
                            DropdownMenuItem(value: ThemeMode.dark, child: Text(l.darkTheme)),
                          ],
                          onChanged: (v) {
                            if (v != null) {
                              setState(() {
                                _tempThemeMode = v;
                                _currentSettings = _currentSettings.copyWith(themeNumber: v.index);
                              });
                            }
                          },
                        ),
                      ),
                      const Divider(height: 40, thickness: 1),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 10),
              LayoutBuilder(
                builder: (context, constraints) {
                  final int width = constraints.maxWidth.isFinite
                      ? constraints.maxWidth.truncate()
                      : MediaQuery.of(context).size.width.truncate();
                  if (width > 0) {
                    WidgetsBinding.instance.addPostFrameCallback((_) {
                      if (mounted) _updateBannerForWidth(width);
                    });
                  }
                  return _isAdLoaded && _adManager.bannerAd != null
                      ? Center(
                          child: SizedBox(
                            width: _adManager.bannerAd!.size.width.toDouble(),
                            height: _adManager.bannerAd!.size.height.toDouble(),
                            child: AdWidget(ad: _adManager.bannerAd!),
                          ),
                        )
                      : const SizedBox.shrink();
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}