ソースコード source code

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

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

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

pubspec.yaml

name: roulette
description: "Roulette"
# 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.1.2+30

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:    #多言語ライブラリの本体    # .arbファイルを更新したら flutter gen-l10n
    sdk: flutter
  intl: ^0.20.2     #多言語やフォーマッタなどの関連ライブラリ
  google_mobile_ads: ^6.0.0
  flutter_tts: ^4.0.2
  just_audio: ^0.10.4
  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'

# The following section is specific to Flutter packages.
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 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'l10n/app_localizations.dart';

import 'package:roulette/models.dart';
import 'package:roulette/ad_manager.dart';
import 'package:roulette/setting_screen.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
  SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
    statusBarColor: Colors.transparent,
    systemNavigationBarColor: Colors.transparent,
  ));
  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();
  }

  _loadThemeAndLocale() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    int themeNumber = prefs.getInt('themeNumber') ?? 0;
    String localeLanguage = prefs.getString('localeLanguage') ?? '';

    setState(() {
      _themeMode = ThemeMode.values[min(themeNumber, ThemeMode.values.length - 1)];
      _locale = localeLanguage.isNotEmpty ? _localeFromTag(localeLanguage) : null; // Use system locale when empty
    });
  }

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

  void _setLocale(String? languageCode) async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    if (languageCode != null && languageCode.isNotEmpty) {
      await prefs.setString('localeLanguage', languageCode);
      setState(() {
        _locale = _localeFromTag(languageCode);
      });
    } else {
      await prefs.remove('localeLanguage');
      setState(() {
        _locale = null; // Use system locale
      });
    }
  }

  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: 'Roulette App',
      themeMode: _themeMode,
      theme: ThemeData(
        primarySwatch: Colors.blueGrey,
        brightness: Brightness.light,
        scaffoldBackgroundColor: Colors.white,
        appBarTheme: const AppBarTheme(
          backgroundColor: Color(0xFF9eabfa), // back_nav
          foregroundColor: Colors.white,
          systemOverlayStyle: SystemUiOverlayStyle.dark, // dark icons on light app bar
        ),
        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, // Buttons background
            foregroundColor: Colors.white, // Buttons text color
            minimumSize: const Size.fromHeight(34), // 34dp height
            shape: const RoundedRectangleBorder(
              borderRadius: BorderRadius.zero, // No rounded corners
            ),
          ),
        ),
      ),
      darkTheme: ThemeData(
        primarySwatch: Colors.blueGrey,
        brightness: Brightness.dark,
        scaffoldBackgroundColor: Colors.black,
        appBarTheme: const AppBarTheme(
          backgroundColor: Color(0xff333333), // back_nav
          foregroundColor: Colors.white,
          systemOverlayStyle: SystemUiOverlayStyle.light, // light icons on dark app bar
        ),
        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, // Buttons background
            foregroundColor: Colors.white, // Buttons text color
            minimumSize: const Size.fromHeight(34), // 34dp height
            shape: const RoundedRectangleBorder(
              borderRadius: BorderRadius.zero, // No rounded corners
            ),
          ),
        ),
      ),
      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 Settings _settings;
  bool _isLoading = true;
  late AnimationController _controller;
  late Animation<double> _animation;
  String? _rouletteResult;
  String? _currentItemName;
  Color? _currentBackgroundColor;
  final _random = Random();
  final Color _fixedBgColor = const Color(0xFFaaaaaa);

  List<Map<String, String>> _availableTtsVoices = [];

  late AdManager _adManager;
  bool _isAdLoaded = false;
  int? _lastBannerWidthDp;

  // Roulette Colors from CustomSurfaceView.kt
  final List<Color> _rouletteColors = [
    const Color.fromARGB(255, 234, 123, 132),
    const Color.fromARGB(255, 240, 196, 123),
    const Color.fromARGB(255, 247, 239, 123),
    const Color.fromARGB(255, 192, 217, 139),
    const Color.fromARGB(255, 123, 197, 156),
    const Color.fromARGB(255, 123, 201, 235),
    const Color.fromARGB(255, 123, 173, 211),
    const Color.fromARGB(255, 138, 139, 189),
    const Color.fromARGB(255, 194, 127, 186),
    const Color.fromARGB(255, 233, 123, 185),
  ];

  final List<Color> _rouletteDarkColors = [
    const Color.fromARGB(255, 222, 0, 17),
    const Color.fromARGB(255, 234, 145, 0),
    const Color.fromARGB(255, 247, 232, 0),
    const Color.fromARGB(255, 137, 188, 30),
    const Color.fromARGB(255, 0, 147, 66),
    const Color.fromARGB(255, 0, 154, 225),
    const Color.fromARGB(255, 0, 101, 176),
    const Color.fromARGB(255, 28, 31, 131),
    const Color.fromARGB(255, 140, 7, 126),
    const Color.fromARGB(255, 220, 0, 123),
  ];

  @override
  void initState() {
    super.initState();
    _adManager = AdManager();
    _initAsyncDependencies();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 10),
    )..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          _determineWinner();
        }
      });
    _animation = Tween<double>(begin: 0, end: 360 * 30).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOutCubic, // Dummy curve, will be replaced in _onClickStart
    ));
  }

  Future<void> _initAsyncDependencies() async {
    _prefs = await SharedPreferences.getInstance();
    _settings = _loadSettings();
    await _initTts();
    _updateColorAndItemNameForAngle(0.0); // Set initial color and item name
    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);
    });
  }

  Settings _loadSettings() {
    List<RouletteItem> itemStates = [];
    for (int i = 1; i <= 20; i++) {
      String name = _prefs.getString('itemName$i') ?? '';
      double rate = _prefs.getDouble('itemRate$i') ?? 1.0;
      itemStates.add(RouletteItem(name, rate));
    }

    // If no items are loaded, set default items
    if (itemStates.every((item) => item.name.isEmpty)) {
      itemStates = Settings.defaultItemStates();
      // Save default items
      for (int i = 0; i < itemStates.length; i++) {
        _prefs.setString('itemName${i + 1}', itemStates[i].name);
        _prefs.setDouble('itemRate${i + 1}', itemStates[i].rate);
      }
    }

    return Settings(
      itemStates: itemStates,
      itemSplit: (_prefs.getInt('itemSplit') ?? 0) == 1,
      fixBackground: (_prefs.getInt('fixBackground') ?? 0) == 1,
      shortenRotation: (_prefs.getInt('shortenRotation') ?? 0) == 1,
      maxSpeedDuration: _prefs.getDouble('maxSpeedDuration') ?? 5.0,
      speechResult: (_prefs.getInt('speechResult') ?? 1) == 1,
      speechVoice: _prefs.getString('speechVoice') ?? '',
      speechLocale: _prefs.getString('speechLocale') ?? '',
      themeNumber: _prefs.getInt('themeNumber') ?? 0,
      localeLanguage: _prefs.getString('localeLanguage') ?? '',
    );
  }

  Future<void> _saveSettings() async {
    for (int i = 0; i < _settings.itemStates.length; i++) {
      await _prefs.setString('itemName${i + 1}', _settings.itemStates[i].name);
      await _prefs.setDouble('itemRate${i + 1}', _settings.itemStates[i].rate);
    }
    await _prefs.setInt('itemSplit', _settings.itemSplit ? 1 : 0);
    await _prefs.setInt('fixBackground', _settings.fixBackground ? 1 : 0);
    await _prefs.setInt('shortenRotation', _settings.shortenRotation ? 1 : 0);
    await _prefs.setDouble('maxSpeedDuration', _settings.maxSpeedDuration);
    await _prefs.setInt('speechResult', _settings.speechResult ? 1 : 0);
    await _prefs.setString('speechVoice', _settings.speechVoice);
    await _prefs.setString('speechLocale', _settings.speechLocale);
    await _prefs.setInt('themeNumber', _settings.themeNumber);
    await _prefs.setString('localeLanguage', _settings.localeLanguage);
  }

  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 (e) {
        // Voice not found, selectedVoice remains null
      }
    }

    if (selectedVoice != null) {
      await flutterTts.setVoice(selectedVoice);
    } else {
      await flutterTts.setLanguage("en-US"); // Default language if no specific voice is selected or found
    }
    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();
  }

  List<RouletteItem> _getActiveItems() {
    final activeItems = _settings.itemStates.where((item) => item.name.isNotEmpty).toList();
    if (_settings.itemSplit && activeItems.isNotEmpty) {
      activeItems.addAll(List.from(activeItems));
    }
    return activeItems;
  }

  void _onClickStart() {
    setState(() {
      _rouletteResult = null; // Clear previous result
      _currentItemName = '...';
    });

    // Define animation phases duration
    final double scale = _settings.shortenRotation ? 0.1 : 1.0;
    final double easeInDuration = 1.0 * scale; // seconds
    final double easeOutDuration = 8.0 * scale; // seconds
    final double linearDuration = _settings.maxSpeedDuration * scale;
    final double totalDuration = easeInDuration + linearDuration + easeOutDuration;

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

    // Adjust rotation amount based on duration to keep speed consistent
    const double baseEaseIn = 1.0;
    const double baseLinearDuration = 5.0; // Default linear duration
    const double baseEaseOut = 8.0;
    const double baseTotalDuration = baseEaseIn + baseLinearDuration + baseEaseOut;
    const double baseRotationAmount = 360 * 28; // A base rotation amount for the base duration
    final double targetRotationAmount = baseRotationAmount * (totalDuration / baseTotalDuration);

    // Add a bit of randomness to the final position
    final double randomExtraRotation = 360 * (_random.nextDouble() - 0.5); // +/- 180 degrees

    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 _updateCurrentItem() {
    final double currentAngle = _animation.value;
    double effectiveAngle = (360 - (currentAngle % 360) + 270) % 360;

    double currentAngleSum = 0.0;
    final List<RouletteItem> activeItems = _getActiveItems();
    double totalRate = activeItems.fold(0.0, (sum, item) => sum + item.rate);

    if (totalRate == 0) {
      _currentItemName = AppLocalizations.of(context)!.noItemsToSpin;
      return;
    }

    for (int i = 0; i < activeItems.length; i++) {
      final item = activeItems[i];
      final double sweepAngle = (item.rate / totalRate) * 360;

      if (effectiveAngle >= currentAngleSum && effectiveAngle < currentAngleSum + sweepAngle) {
        final originalItemsCount = _settings.itemStates.where((i) => i.name.isNotEmpty).length;
        if (originalItemsCount == 0) return;

        _currentItemName = item.name;
        final Color segColor = _rouletteColors[i % originalItemsCount % _rouletteColors.length];
        if (_controller.isAnimating && _settings.fixBackground) {
          _currentBackgroundColor = _fixedBgColor;
        } else {
          _currentBackgroundColor = segColor;
        }
        return;
      }
      currentAngleSum += sweepAngle;
    }
  }

  void _determineWinner() {
    final double finalAngle = _animation.value; // This is 0-360 degrees

    // Assuming the pointer is at the "top" of the wheel, which is 0 degrees if we consider the top as the reference.
    // The animation value is the total rotation.
    // We need to find which segment is at the 0-degree mark after the rotation.

    // The angle on the unrotated wheel that is now at the pointer (top = 270 degrees).
    // If the wheel rotated by `finalAngle` clockwise, then the segment that was originally at `(270 - finalAngle) % 360` is now at the top.
    // Our drawing logic has 0 degrees on the right. So top is 270.
    double effectiveAngle = (360 - (finalAngle % 360) + 270) % 360;

    double currentAngle = 0.0;
    final List<RouletteItem> activeItems = _getActiveItems();
    double totalRate = activeItems.fold(0.0, (sum, item) => sum + item.rate);

    if (totalRate == 0) {
      setState(() {
        _rouletteResult = AppLocalizations.of(context)!.noItemsToSpin;
      });
      return;
    }

    for (int i = 0; i < activeItems.length; i++) {
      final item = activeItems[i];
      final double sweepAngle = (item.rate / totalRate) * 360; // in degrees

      if (effectiveAngle >= currentAngle && effectiveAngle < currentAngle + sweepAngle) {
        setState(() {
          final originalItemsCount = _settings.itemStates.where((i) => i.name.isNotEmpty).length;
          if (originalItemsCount == 0) return;

          _rouletteResult = item.name;
          _currentItemName = item.name;
          _currentBackgroundColor = _rouletteColors[i % originalItemsCount % _rouletteColors.length];
          if (_settings.speechResult) {
            flutterTts.speak(item.name);
          }
        });
        return;
      }
      currentAngle += sweepAngle;
    }

    setState(() {
      _rouletteResult = AppLocalizations.of(context)!.errorDeterminingWinner;
    });
  }

  void _updateColorAndItemNameForAngle(double angle) {
    // This method calculates the color and item name for a given angle.
    double effectiveAngle = (360 - (angle % 360) + 270) % 360;

    double currentAngleSum = 0.0;
    final List<RouletteItem> activeItems = _getActiveItems();
    double totalRate = activeItems.fold(0.0, (sum, item) => sum + item.rate);

    if (totalRate == 0) {
      setState(() {
        _currentItemName = AppLocalizations.of(context)?.noItemsToSpin;
        _currentBackgroundColor = null; // Or a default color
      });
      return;
    }

    for (int i = 0; i < activeItems.length; i++) {
      final item = activeItems[i];
      final double sweepAngle = (item.rate / totalRate) * 360;

      if (effectiveAngle >= currentAngleSum && effectiveAngle < currentAngleSum + sweepAngle) {
        setState(() {
          final originalItemsCount = _settings.itemStates.where((i) => i.name.isNotEmpty).length;
          if (originalItemsCount == 0) return;

          // When the wheel is not spinning, this sets the result.
          if (!_controller.isAnimating) {
            _rouletteResult = item.name;
          }
          _currentItemName = item.name;
          _currentBackgroundColor = _rouletteColors[i % originalItemsCount % _rouletteColors.length];
        });
        return;
      }
      currentAngleSum += sweepAngle;
    }
  }

  void _onClickSetting() async {
    final updatedSettings = await Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => SettingScreen(initialSettings: _settings),
      ),
    );

    if (updatedSettings != null) {
      setState(() {
        _settings = updatedSettings;
      });
      await _saveSettings();
      widget.setTheme(_settings.themeNumber);
      widget.setLocale(_settings.localeLanguage.isEmpty ? null : _settings.localeLanguage);
      await _initTts(); // Re-initialize TTS to apply new voice settings
      _updateColorAndItemNameForAngle(_animation.value); // Recalculate color and item name for the current angle
    }
  }

  @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, child) {
        _updateCurrentItem();
        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: [
                    // Progress bar below the app bar
                    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(
                      height: 70.0,
                      child: Visibility(
                        visible: _controller.isAnimating || _rouletteResult != null,
                        maintainState: true,
                        maintainAnimation: true,
                        maintainSize: true,
                        child: Padding(
                          padding: const EdgeInsets.all(16.0),
                          child: Text(
                            _currentItemName ?? _rouletteResult ?? '',
                            style: Theme.of(context).textTheme.headlineMedium?.copyWith(
                              color: Theme.of(context).brightness == Brightness.light ? Colors.white : Colors.black,
                            ),
                            textAlign: TextAlign.center,
                          ),
                        ),
                      ),
                    ),
                    Expanded(
                      flex: 6,
                      child: Stack(
                        children: [
                          Positioned.fill(
                            child: Center(
                              child: CustomPaint(
                                painter: RoulettePainter(
                                  animationValue: _animation.value,
                                  activeItems: _getActiveItems(),
                                  settings: _settings,
                                  rouletteColors: _rouletteColors,
                                  rouletteDarkColors: _rouletteDarkColors,
                                ),
                                child: Container(),
                              ),
                            ),
                          ),
                          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 RoulettePainter extends CustomPainter {
  final double animationValue;
  final List<RouletteItem> activeItems;
  final Settings settings;
  final List<Color> rouletteColors;
  final List<Color> rouletteDarkColors;

  RoulettePainter({
    required this.animationValue,
    required this.activeItems,
    required this.settings,
    required this.rouletteColors,
    required this.rouletteDarkColors,
  });

  @override
  void paint(Canvas canvas, Size size) {
    // Implement roulette drawing logic here based on CustomSurfaceView.kt
    // This is a placeholder for now.
    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;
    // Draw a 359-degree arc to create a 1-degree gap at the top.
    final double gap = pi / 180; // 1 degree in radians
    canvas.drawArc(
      Rect.fromCircle(center: Offset(centerX, centerY), radius: radius + 10),
      -pi / 2 + gap / 2, // Start angle (top is -pi/2), offset by half the gap
      2 * pi - gap, // Sweep angle (359 degrees)
      true,
      whitePaint,
    );

    double startAngle = animationValue * (pi / 180); // Convert degrees to radians

    // Filter out empty items and calculate total rate for active items
    double totalRate = activeItems.fold(0.0, (sum, item) => sum + item.rate);
    if (totalRate == 0) return;

    final originalItemsCount = settings.itemStates.where((i) => i.name.isNotEmpty).length;
    if (originalItemsCount == 0) return;

    for (int i = 0; i < activeItems.length; i++) {
      final item = activeItems[i];

      final double sweepAngle = (item.rate / totalRate) * 2 * pi;

      final Paint segmentPaint = Paint()..color = rouletteColors[i % originalItemsCount % rouletteColors.length];
      canvas.drawArc(
        Rect.fromCircle(center: Offset(centerX, centerY), radius: radius),
        startAngle,
        sweepAngle,
        true,
        segmentPaint,
      );

      final Paint darkSegmentPaint = Paint()..color = rouletteDarkColors[i % originalItemsCount % rouletteDarkColors.length];
      canvas.drawArc(
        Rect.fromCircle(center: Offset(centerX, centerY), radius: radius / 2),
        startAngle,
        sweepAngle,
        true,
        darkSegmentPaint,
      );

      // Draw text
      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);

      final TextPainter textPainter = TextPainter(
        text: TextSpan(
          text: item.name,
          style: const TextStyle(color: Colors.black, fontSize: 16),
        ),
        textDirection: TextDirection.ltr,
      );
      textPainter.layout();
      canvas.save();
      canvas.translate(textX, textY);
      canvas.rotate(textAngle + pi / 2); // Rotate text to align with segment
      textPainter.paint(canvas, Offset(-textPainter.width / 2, -textPainter.height / 2));
      canvas.restore();

      startAngle += sweepAngle;
    }
  }

  @override
  bool shouldRepaint(covariant RoulettePainter oldDelegate) {
    return oldDelegate.animationValue != animationValue ||
        !listEquals(oldDelegate.activeItems, activeItems) ||
        oldDelegate.settings != settings;
  }
}

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 totalDuration = easeInDuration + linearDuration + easeOutDuration;
    final easeInFraction = easeInDuration / totalDuration;
    final linearFraction = linearDuration / totalDuration;

    // Calculate the total distance traveled in each phase if the max speed is 1.0
    final distEaseIn = 0.5 * easeInDuration; // Area of triangle
    final distLinear = 1.0 * linearDuration;
    final distEaseOut = 0.5 * easeOutDuration; // Area of triangle
    final totalDistance = distEaseIn + distLinear + distEaseOut;

    if (t < easeInFraction) {
      // Phase 1: Ease-in (Quadratic ease-in, v = at)
      final timeInPhase = t * totalDuration;
      final distance = 0.5 * timeInPhase * timeInPhase / easeInDuration;
      return distance / totalDistance;
    } else if (t < easeInFraction + linearFraction) {
      // Phase 2: Linear
      final timeInPhase = (t - easeInFraction) * totalDuration;
      final distance = distEaseIn + timeInPhase;
      return distance / totalDistance;
    } else {
      // Phase 3: Ease-out (Quadratic ease-out)
      final timeInPhase = (t - easeInFraction - linearFraction) * totalDuration;
      final initialVelocity = 1.0;
      final acceleration = -initialVelocity / easeOutDuration;
      final distance = distEaseIn + distLinear + (initialVelocity * timeInPhase + 0.5 * acceleration * timeInPhase * timeInPhase);
      return distance / totalDistance;
    }
  }
}

lib/models.dart


import 'package:equatable/equatable.dart';

class RouletteItem extends Equatable {
  final String name;
  final double rate;

  const RouletteItem(this.name, this.rate);

  @override
  List<Object?> get props => [name, rate];

  RouletteItem copyWith({String? name,double? rate}) {
    return RouletteItem(
      name ?? this.name,
      rate ?? this.rate,
    );
  }

  Map<String, dynamic> toJson() => {
    'name': name,
    'rate': rate,
  };

  factory RouletteItem.fromJson(Map<String, dynamic> json) {
    return RouletteItem(json['name'], json['rate']);
  }
}

class Settings extends Equatable {
  final List<RouletteItem> itemStates;
  final bool itemSplit;
  final bool fixBackground;
  final bool shortenRotation;
  final double maxSpeedDuration;
  final bool speechResult;
  final String speechVoice;
  final String speechLocale;
  final int themeNumber;
  final String localeLanguage;

  const Settings({
    required this.itemStates,
    this.itemSplit = false,
    this.fixBackground = false,
    this.shortenRotation = false,
    this.maxSpeedDuration = 5.0,
    this.speechResult = true,
    this.speechVoice = '',
    this.speechLocale = '',
    this.themeNumber = 0,
    this.localeLanguage = '',
  });

  Settings copyWith({
    List<RouletteItem>? itemStates,
    bool? itemSplit,
    bool? fixBackground,
    bool? shortenRotation,
    double? maxSpeedDuration,
    bool? speechResult,
    String? speechVoice,
    String? speechLocale,
    int? themeNumber,
    String? localeLanguage,
  }) {
    return Settings(
      itemStates: itemStates ?? this.itemStates,
      itemSplit: itemSplit ?? this.itemSplit,
      fixBackground: fixBackground ?? this.fixBackground,
      shortenRotation: shortenRotation ?? this.shortenRotation,
      maxSpeedDuration: maxSpeedDuration ?? this.maxSpeedDuration,
      speechResult: speechResult ?? this.speechResult,
      speechVoice: speechVoice ?? this.speechVoice,
      speechLocale: speechLocale ?? this.speechLocale,
      themeNumber: themeNumber ?? this.themeNumber,
      localeLanguage: localeLanguage ?? this.localeLanguage,
    );
  }

  @override
  List<Object?> get props => [
    itemStates,
    itemSplit,
    fixBackground,
    shortenRotation,
    maxSpeedDuration,
    speechResult,
    speechVoice,
    speechLocale,
    themeNumber,
    localeLanguage,
  ];

  // Default settings for initial load
  static List<RouletteItem> defaultItemStates() {
    return [
      const RouletteItem('Item 1', 1.0),
      const RouletteItem('Item 2', 1.0),
      const RouletteItem('Item 3', 1.0),
      const RouletteItem('Item 4', 1.0),
      const RouletteItem('Item 5', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
      const RouletteItem('', 1.0),
    ];
  }
}

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 'l10n/app_localizations.dart';

import 'package:roulette/ad_manager.dart';
import 'package:roulette/models.dart';

class SettingScreen extends StatefulWidget {
  final Settings initialSettings;
  const SettingScreen({super.key, required this.initialSettings});
  @override
  State<SettingScreen> createState() => _SettingScreenState();
}

class _SettingScreenState extends State<SettingScreen> {
  late Settings _currentSettings;
  late FlutterTts flutterTts;
  late ThemeMode _tempThemeMode;
  String? _selectedLocaleTag;
  List<Map<String, String>> _speechVoices = [];
  Map<String, String>? _selectedSpeechVoice;

  final List<TextEditingController> _nameControllers = [];
  final List<TextEditingController> _rateControllers = [];

  late AdManager _adManager;
  bool _isAdLoaded = false;
  int? _lastBannerWidthDp;
  int _visibleItemCount = 5;


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

  @override
  void initState() {
    super.initState();
    _currentSettings = widget.initialSettings;
    _adManager = AdManager();

    final lastNonEmptyIndex = widget.initialSettings.itemStates.lastIndexWhere((item) => item.name.isNotEmpty);
    final itemsToShow = lastNonEmptyIndex + 1;
    _visibleItemCount = max(5, itemsToShow);

    _tempThemeMode = ThemeMode.values[_currentSettings.themeNumber];
    _selectedLocaleTag = _currentSettings.localeLanguage.isEmpty ? null : _currentSettings.localeLanguage;

    for (int i = 0; i < _currentSettings.itemStates.length; i++) {
      _nameControllers.add(TextEditingController(text: _currentSettings.itemStates[i].name));
      _rateControllers.add(TextEditingController(text: _currentSettings.itemStates[i].rate.toString()));
    }

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

  void _incrementVisibleItems() {
    setState(() {
      _visibleItemCount = min(20, _visibleItemCount + 1);
    });
  }

  void _decrementVisibleItems() {
    setState(() {
      if (_visibleItemCount > 5) {
        final newCount = _visibleItemCount - 1;
        _nameControllers[newCount].text = '';
        _rateControllers[newCount].text = '1.0';
        _visibleItemCount = newCount;
      }
    });
  }

  Future<void> _initTts() async {
    flutterTts = FlutterTts();
    _speechVoices = (await flutterTts.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']!));

    setState(() {
      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 (e) {
          // Voice not found, foundVoice remains null
        }
      }
      _selectedSpeechVoice = foundVoice;
    });
  }

  @override
  void dispose() {
    for (var controller in _nameControllers) {
      controller.dispose();
    }
    for (var controller in _rateControllers) {
      controller.dispose();
    }
    _adManager.dispose();
    super.dispose();
  }

  void _onApply() {
    final newItemStates = List<RouletteItem>.generate(
      _currentSettings.itemStates.length,
      (i) => _currentSettings.itemStates[i].copyWith(
        name: _nameControllers[i].text,
        rate: double.tryParse(_rateControllers[i].text) ?? 1.0,
      ),
    );

    final newSettings = _currentSettings.copyWith(
      itemStates: newItemStates,
      speechVoice: _selectedSpeechVoice?['name'] ?? '',
      speechLocale: _selectedSpeechVoice?['locale'] ?? '',
      localeLanguage: _selectedLocaleTag ?? '',
    );

    Navigator.pop(context, newSettings);
  }

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

  @override
  Widget build(BuildContext context) {
    final localizations = 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: 16, vertical: 20),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      _ItemRatioSection(
                        localizations: localizations,
                        visibleItemCount: _visibleItemCount,
                        nameControllers: _nameControllers,
                        rateControllers: _rateControllers,
                        onIncrement: _incrementVisibleItems,
                        onDecrement: _decrementVisibleItems,
                      ),
                      const Divider(height: 40, thickness: 1),
                      SwitchListTile(
                        title: Text(localizations.splitItem),
                        subtitle: Text(localizations.split),
                        value: _currentSettings.itemSplit,
                        onChanged: (bool value) {
                          setState(() {
                            _currentSettings = _currentSettings.copyWith(itemSplit: value);
                          });
                        },
                      ),
                      SwitchListTile(
                        title: Text(localizations.fixBackgroundWhileSpinning),
                        value: _currentSettings.fixBackground,
                        onChanged: (bool value) {
                          setState(() {
                            _currentSettings = _currentSettings.copyWith(fixBackground: value);
                          });
                        },
                      ),
                      const Divider(height: 40, thickness: 1),
                      Padding(
                        padding: const EdgeInsets.symmetric(horizontal: 16.0),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(localizations.maxSpeedDuration, style: Theme.of(context).textTheme.titleMedium),
                            const SizedBox(height: 4),
                            Text(localizations.maxSpeedDuration1),
                            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);
                                      });
                                    },
                                  ),
                                ),
                                Text(
                                  _currentSettings.maxSpeedDuration.toInt().toString().padLeft(2, '0'),
                                  style: Theme.of(context).textTheme.titleMedium,
                                ),
                              ],
                            ),
                            const SizedBox(height: 4),
                          ],
                        ),
                      ),
                      // Place the switch outside the inner padding to align left/right with others
                      SwitchListTile(
                        title: Text(localizations.shortenRotation),
                        value: _currentSettings.shortenRotation,
                        onChanged: (bool value) {
                          setState(() {
                            _currentSettings = _currentSettings.copyWith(shortenRotation: value);
                          });
                        },
                      ),
                      const Divider(height: 40, thickness: 1),
                      SwitchListTile(
                        title: Text(localizations.speechResult),
                        subtitle: Text(localizations.speechResult1),
                        value: _currentSettings.speechResult,
                        onChanged: (bool value) {
                          setState(() {
                            _currentSettings = _currentSettings.copyWith(speechResult: value);
                          });
                        },
                      ),
                      const SizedBox(height: 10),
                      Padding(
                        padding: const EdgeInsets.symmetric(horizontal: 16.0),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(localizations.speechVoice),
                            SizedBox(height: 4),
                            DropdownButton<Map<String, String>>(
                              value: _selectedSpeechVoice,
                              items: _speechVoices.map((Map<String, String> voice) {
                                return DropdownMenuItem<Map<String, String>>(
                                  value: voice,
                                  child: Text('${voice['locale']} - ${voice['name']}'),
                                );
                              }).toList(),
                              onChanged: (newValue) {
                                setState(() {
                                  _selectedSpeechVoice = newValue;
                                });
                              },
                            ),
                          ],
                        ),
                      ),
                      const Divider(height: 40, thickness: 1),
                      ListTile(
                                                title: Text(AppLocalizations.of(context)!.language),
                        trailing: DropdownButton<String?>(
                          value: _selectedLocaleTag,
                          hint: Text(AppLocalizations.of(context)!.systemDefault),
                          items: [
                            DropdownMenuItem<String?>(
                              value: null,
                              child: Text(AppLocalizations.of(context)!.systemDefault),
                            ),
                            ...languageOptions.entries.map(
                              (entry) => DropdownMenuItem<String?>(
                                value: entry.key,
                                child: Text(entry.value),
                              ),
                            ),
                          ],
                          onChanged: (value) {
                            setState(() {
                              _selectedLocaleTag = value;
                            });
                          },
                        ),
                      ),
                      const Divider(height: 40, thickness: 1),
                      ListTile(
                        title: Text(AppLocalizations.of(context)!.theme),
                                                trailing: DropdownButton<ThemeMode>(
                          value: _tempThemeMode,
                          items: [
                            DropdownMenuItem(
                              value: ThemeMode.system,
                              child: Text(AppLocalizations.of(context)!.systemDefault),
                            ),
                            DropdownMenuItem(
                              value: ThemeMode.light,
                              child: Text(AppLocalizations.of(context)!.lightTheme),
                            ),
                            DropdownMenuItem(
                              value: ThemeMode.dark,
                              child: Text(AppLocalizations.of(context)!.darkTheme),
                            ),
                          ],
                          onChanged: (value) {
                            if (value != null) {
                              setState(() {
                                _tempThemeMode = value;
                                _currentSettings = _currentSettings.copyWith(themeNumber: value.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();
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _ItemRatioSection extends StatelessWidget {
  final AppLocalizations localizations;
  final int visibleItemCount;
  final List<TextEditingController> nameControllers;
  final List<TextEditingController> rateControllers;
  final VoidCallback onIncrement;
  final VoidCallback onDecrement;

  const _ItemRatioSection({
    required this.localizations,
    required this.visibleItemCount,
    required this.nameControllers,
    required this.rateControllers,
    required this.onIncrement,
    required this.onDecrement,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          localizations.itemRatio,
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 10),
        Table(
          columnWidths: const {
            0: FlexColumnWidth(0.2),
            1: FlexColumnWidth(2),
            2: FlexColumnWidth(1),
          },
          border: TableBorder.all(color: Colors.transparent),
          children: List.generate(visibleItemCount, (index) {
            return TableRow(
              children: [
                Padding(
                  padding: const EdgeInsets.only(top: 17.0),
                  child: Text((index + 1).toString()),
                ),
                Padding(
                  padding: const EdgeInsets.only(top: 8.0),
                  child: TextField(
                    controller: nameControllers[index],
                    decoration: const InputDecoration(
                      border: OutlineInputBorder(),
                      isDense: true,
                      contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
                    ),
                    keyboardType: TextInputType.text,
                  ),
                ),
                Padding(
                  padding: const EdgeInsets.only(top: 8.0,left: 6.0),
                  child: TextField(
                    controller: rateControllers[index],
                    decoration: const InputDecoration(
                      border: OutlineInputBorder(),
                      isDense: true,
                      contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
                    ),
                    keyboardType: TextInputType.number,
                    inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))],
                  ),
                ),
              ],
            );
          }),
        ),
        const SizedBox(height: 10),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            IconButton(
              icon: const Icon(Icons.remove_circle_outline),
              onPressed: onDecrement,
            ),
            const SizedBox(width: 20),
            IconButton(
              icon: const Icon(Icons.add_circle_outline),
              onPressed: onIncrement,
            ),
          ],
        ),
      ],
    );
  }
}