ソースコード source code

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

● ご注意 ● このページで公開しているのはDartコードのみです。アプリの動作には画像・音声・多言語テキストなどの追加データが必要なため、ここに掲載している情報だけではアプリを完全に再現することはできません。 ● アプリは継続的に機能拡張しているため、ストアで公開している最新版とコード内容が異なる場合があります。 ● コードはコピーして自由にご利用いただけますが、著作権は放棄していません。そのため、コード全体の再掲載はご遠慮ください。一部の引用や改変しての再掲載は問題ありません。個人利用・商用利用を問わず、アプリ開発の参考として自由にお使いください。 ● このコードは「お手本」として公開しているものではありません。ミニアプリとして作成しているため、変数名など細部に配慮していない箇所や誤りが含まれる可能性があります。参考程度にご覧ください。 ● 他の開発者の公開コードを参考にした部分も含まれています。アプリ開発の熟練者が書いたコードではない点もご了承ください。 ● ここはエンジニア向けの技術情報共有サービスではないため、詳細な説明は省略しています。GitHub などでの公開予定はありません。 ● 本コードは無保証であり、動作や品質を保証するものではありません。
Copyright© エーオーシステム株式会社

● Notice ● Only the Dart source code is provided on this page. Additional assets such as images, audio files, and multilingual text are required for the application to function. Therefore, the information here alone is not sufficient to fully reproduce the app. ● The app is continuously being improved, so the code published here may differ from the latest version available on the app stores. ● You are free to copy and use the code, but the copyright is not waived. For this reason, please refrain from redistributing (reposting) the entire code. Partial quotation or redistribution of modified portions is allowed. You may use the code freely for reference in your own app development, whether for personal or commercial use. ● This code is not intended to serve as a coding example or best practice. As this is a small sample app, some variable names and details may lack refinement, and there may be mistakes. Please use it only as a reference. ● Some parts of the code are based on publicly available examples from other developers. Please note that this code was not written by an expert in Android app development. ● This is not a technical knowledge-sharing service for engineers, so detailed explanations are omitted. There are no plans to publish the code on GitHub. ● The code is provided "as is" without any warranty of any kind.
Copyright© ao-system, Inc.

下記コードの最終ビルド日: 2025-10-02

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.8+35

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: ^6.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_banner_widget.dart

import 'package:flutter/cupertino.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';

import 'package:numberroulette/ad_manager.dart';

class AdBannerWidget extends StatefulWidget {
  final AdManager adManager;
  const AdBannerWidget({super.key, required this.adManager});
  @override
  State<AdBannerWidget> createState() => _AdBannerWidgetState();
}

class _AdBannerWidgetState extends State<AdBannerWidget> {
  int _lastBannerWidthDp = 0;
  bool _isAdLoaded = false;
  bool _isLoading = false;
  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: LayoutBuilder(
        builder: (context, constraints) {
          final int width = constraints.maxWidth.isFinite ? constraints.maxWidth.truncate() : MediaQuery.of(context).size.width.truncate();
          final bannerAd = widget.adManager.bannerAd;
          if (width > 0) {
            WidgetsBinding.instance.addPostFrameCallback((_) {
              if (mounted) {
                final bannerAd = widget.adManager.bannerAd;
                final bool widthChanged = _lastBannerWidthDp != width;
                final bool sizeMismatch = bannerAd == null || bannerAd.size.width != width;
                if ((widthChanged || !_isAdLoaded || sizeMismatch) && !_isLoading) {
                  _lastBannerWidthDp = width;
                  setState(() { _isAdLoaded = false; _isLoading = true; });
                  widget.adManager.loadAdaptiveBannerAd(width, () {
                    if (mounted) {
                      setState(() { _isAdLoaded = true; _isLoading = false; });
                    }
                  });
                }
              }
            });
          }
          if (_isAdLoaded && bannerAd != null) {
            return Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                SizedBox(height: 10),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    SizedBox(
                      width: bannerAd.size.width.toDouble(),
                      height: bannerAd.size.height.toDouble(),
                      child: AdWidget(ad: bannerAd),
                    ),
                  ],
                )
              ]
            );
          } else {
            return const SizedBox.shrink();
          }
        },
      ),
    );
  }
}

lib/ad_manager.dart

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

class AdManager {
  // Test IDs
  // static const String _androidAdUnitId = "ca-app-pub-3940256099942544/6300978111";
  // static const String _iosAdUnitId     = "ca-app-pub-3940256099942544/2934735716";

  // Production IDs
  static const String _androidAdUnitId = "ca-app-pub-0/0";
  static const String _iosAdUnitId     = "ca-app-pub-0/0";

  static String get _adUnitId => Platform.isIOS ? _iosAdUnitId : _androidAdUnitId;

  BannerAd? _bannerAd;
  int _lastWidthPx = 0;
  VoidCallback? _onLoadedCb;
  Timer? _retryTimer;
  int _retryAttempt = 0;

  BannerAd? get bannerAd => _bannerAd;

  Future<void> loadAdaptiveBannerAd(int widthPx, VoidCallback onAdLoaded) async {
    _onLoadedCb = onAdLoaded;
    _lastWidthPx = widthPx;
    _retryAttempt = 0;
    _retryTimer?.cancel();
    _startLoad(widthPx);
  }

  Future<void> _startLoad(int widthPx) async {
    _bannerAd?.dispose();

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

    _bannerAd = BannerAd(
      adUnitId: _adUnitId,
      request: const AdRequest(),
      size: size,
      listener: BannerAdListener(
        onAdLoaded: (ad) {
          _retryTimer?.cancel();
          _retryAttempt = 0;
          final cb = _onLoadedCb;
          if (cb != null) {
            cb();
          }
        },
        onAdFailedToLoad: (ad, err) {
          ad.dispose();
          _scheduleRetry();
        },
      ),
    )..load();
  }

  void _scheduleRetry() {
    _retryTimer?.cancel();
    // Exponential backoff: 3s, 6s, 12s, max 30s
    _retryAttempt = (_retryAttempt + 1).clamp(1, 5);
    final seconds = _retryAttempt >= 4 ? 30 : (3 << (_retryAttempt - 1));
    _retryTimer = Timer(Duration(seconds: seconds), () {
      _startLoad(_lastWidthPx > 0 ? _lastWidthPx : 320);
    });
  }

  void dispose() {
    _bannerAd?.dispose();
    _retryTimer?.cancel();
  }

}

lib/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/ad_banner_widget.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;

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

  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),
                  ]
                ),
              ],
            ),
          ),
          bottomNavigationBar: AdBannerWidget(adManager: _adManager),
        );
      },
    );
  }
}

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:numberroulette/ad_manager.dart';
import 'package:numberroulette/ad_banner_widget.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();

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

  late AdManager _adManager;

  Color _backColor = Colors.grey[200]!;
  Color _cardColor = Colors.white;
  bool _onceOnly = false;

  // 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();
    _adManager = AdManager();
    _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();
    _initTts();
  }

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

  @override
  Widget build(BuildContext context) {
    if (!_onceOnly) {
      _onceOnly = true;
      final bool isLightTheme = Theme.of(context).brightness == Brightness.light;
      _backColor = isLightTheme ? Colors.grey[200]! : Colors.grey[900]!;
      _cardColor = isLightTheme ? Colors.white : Colors.grey[800]!;
    }
    return Scaffold(
      backgroundColor: _backColor,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        leading: IconButton(
          icon: const Icon(Icons.close, color: Colors.grey),
          onPressed: () => Navigator.of(context).pop()
        ),
        actions: [
          IconButton(icon: const Icon(Icons.check, color: Colors.grey), 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: 4, vertical: 8),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      _buildNumberRange(),
                      _buildTextSize(),
                      _buildOrder(),
                      _buildFix(),
                      _buildRotation(),
                      _buildSpeak(),
                      _buildTheme(),
                      _buildLanguage(),
                      const SizedBox(height: 100),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
      bottomNavigationBar: AdBannerWidget(adManager: _adManager),
    );
  }

  Widget _buildNumberRange() {
    final l = AppLocalizations.of(context);
    return Card(
      color: _cardColor,
      elevation: 0,
      shadowColor: Colors.transparent,
      surfaceTintColor: Colors.transparent,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(l.numberRange, style: Theme.of(context).textTheme.bodyLarge),
            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],
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildTextSize() {
    final l = AppLocalizations.of(context);
    return Column(children:[
      Card(
        margin: const EdgeInsets.only(left: 4, top: 8, right: 4, bottom: 0),
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(12),
            topRight: Radius.circular(12),
            bottomLeft: Radius.circular(0),
            bottomRight: Radius.circular(0),
          ),
        ),
        color: _cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: Padding(
          padding: const EdgeInsets.only(left: 0, top: 16, right: 16, bottom: 0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding: const EdgeInsets.only(left: 16),
                child: Text(l.textSizeAdjustResult, style: Theme.of(context).textTheme.bodyLarge),
              ),
              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.bodyLarge,
                  ),
                ],
              ),
            ],
          ),
        )
      ),
      Card(
        margin: const EdgeInsets.only(left: 4, top: 2, right: 4, bottom: 0),
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(0),
            topRight: Radius.circular(0),
            bottomLeft: Radius.circular(12),
            bottomRight: Radius.circular(12),
          ),
        ),
        color: _cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: Padding(
          padding: const EdgeInsets.only(left: 0, top: 16, right: 16, bottom: 0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding: const EdgeInsets.only(left: 16.0),
                child: Text(l.textSizeAdjustRoulette, style: Theme.of(context).textTheme.bodyLarge),
              ),
              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.bodyLarge,
                  ),
                ],
              ),
            ],
          ),
        ),
      )
    ]);
  }

  Widget _buildOrder() {
    final l = AppLocalizations.of(context);
    return Column(children:[
      Card(
        margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
        color: _cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(12),
            topRight: Radius.circular(12),
            bottomLeft: Radius.circular(0),
            bottomRight: Radius.circular(0),
          ),
        ),
        child: Padding(
          padding: const EdgeInsets.only(left: 16, top: 4, right: 16, bottom: 0),
          child: Column(children:[
            SwitchListTile(
              contentPadding: const EdgeInsets.all(0),
              title: Text(l.reverseOrder),
              value: _currentSettings.positionReverse,
              onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(positionReverse: v, positionRandom: v ? false : _currentSettings.positionRandom)),
            ),
          ])
        )
      ),
      Card(
        margin: const EdgeInsets.only(left: 4, top: 2, right: 4, bottom: 0),
        color: _cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(0),
            topRight: Radius.circular(0),
            bottomLeft: Radius.circular(12),
            bottomRight: Radius.circular(12),
          ),
        ),
        child: Padding(
          padding: const EdgeInsets.only(left: 16, top: 4, right: 16, bottom: 4),
          child: Column(children:[
            SwitchListTile(
              contentPadding: const EdgeInsets.all(0),
              title: Text(l.randomizeOrder),
              value: _currentSettings.positionRandom,
              onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(positionRandom: v, positionReverse: v ? false : _currentSettings.positionReverse)),
            ),
          ])
        )
      )
    ]);
  }

  Widget _buildFix() {
    final l = AppLocalizations.of(context);
    return Card(
      margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
      color: _cardColor,
      elevation: 0,
      shadowColor: Colors.transparent,
      surfaceTintColor: Colors.transparent,
      child: Padding(
        padding: const EdgeInsets.only(left: 16, top: 8, right: 16, bottom: 8),
        child: Column(children:[
          SwitchListTile(
            contentPadding: const EdgeInsets.only(left: 0, top: 0, right: 0, bottom: 0),
            title: Text(l.fixBackgroundWhileSpinning),
            value: _currentSettings.fixBackground,
            onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(fixBackground: v)),
          ),
        ])
      )
    );
  }

  Widget _buildRotation() {
    final l = AppLocalizations.of(context);
    return SizedBox(
      width: double.infinity,
      child: Column(children:[
        Card(
          margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
          color: _cardColor,
          elevation: 0,
          shadowColor: Colors.transparent,
          surfaceTintColor: Colors.transparent,
          shape: const RoundedRectangleBorder(
            borderRadius: BorderRadius.only(
              topLeft: Radius.circular(12),
              topRight: Radius.circular(12),
              bottomLeft: Radius.circular(0),
              bottomRight: Radius.circular(0),
            ),
          ),
          child: Padding(
            padding: const EdgeInsets.only(left: 0, top: 16, right: 16, bottom: 0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Padding(
                  padding: const EdgeInsets.only(left: 16,right: 16),
                  child: Text(l.rotationTime, style: Theme.of(context).textTheme.bodyLarge),
                ),
                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.bodyLarge,
                      ),
                    )
                  ],
                ),
              ],
            ),
          )
        ),
        Card(
          margin: const EdgeInsets.only(left: 4, top: 2, right: 4, bottom: 0),
          color: _cardColor,
          elevation: 0,
          shadowColor: Colors.transparent,
          surfaceTintColor: Colors.transparent,
          shape: const RoundedRectangleBorder(
            borderRadius: BorderRadius.only(
              topLeft: Radius.circular(0),
              topRight: Radius.circular(0),
              bottomLeft: Radius.circular(12),
              bottomRight: Radius.circular(12),
            ),
          ),
          child: Padding(
            padding: const EdgeInsets.only(left: 0, top: 8, right: 16, bottom: 8),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                SwitchListTile(
                  contentPadding: const EdgeInsets.only(left: 16),
                  title: Text(l.shortenRotation),
                  value: _currentSettings.shortRotation,
                  onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(shortRotation: v)),
                ),
              ],
            ),
          )
        )
      ])
    );
  }

  Widget _buildSpeak() {
    final l = AppLocalizations.of(context);
    return SizedBox(
      width: double.infinity,
      child: Column(children:[
        Card(
          margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
          color: _cardColor,
          elevation: 0,
          shadowColor: Colors.transparent,
          surfaceTintColor: Colors.transparent,
          shape: const RoundedRectangleBorder(
            borderRadius: BorderRadius.only(
              topLeft: Radius.circular(12),
              topRight: Radius.circular(12),
              bottomLeft: Radius.circular(0),
              bottomRight: Radius.circular(0),
            ),
          ),
          child: Padding(
            padding: const EdgeInsets.only(left: 16, top: 8, right: 16, bottom: 8),
            child: SwitchListTile(
              contentPadding: const EdgeInsets.only(left: 0, top: 0, right: 0, bottom: 0),
              title: Text(l.speechResult),
              value: _currentSettings.speechNumber,
              onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(speechNumber: v)),
            ),
          )
        ),
        Card(
          margin: const EdgeInsets.only(left: 4, top: 2, right: 4, bottom: 0),
          color: _cardColor,
          elevation: 0,
          shadowColor: Colors.transparent,
          surfaceTintColor: Colors.transparent,
          shape: const RoundedRectangleBorder(
            borderRadius: BorderRadius.only(
              topLeft: Radius.circular(0),
              topRight: Radius.circular(0),
              bottomLeft: Radius.circular(12),
              bottomRight: Radius.circular(12),
            ),
          ),
          child: Padding(
            padding: const EdgeInsets.only(left: 16, top: 16, right: 16, bottom: 8),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(l.speechVoice,style: Theme.of(context).textTheme.bodyLarge),
                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),
                ),
              ],
            ),
          )
        )
      ])
    );
  }

  Widget _buildTheme() {
    final l = AppLocalizations.of(context);
    return SizedBox(
      width: double.infinity,
      child: Card(
        margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
        color: _cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: ListTile(
          contentPadding: const EdgeInsets.only(left: 16, top: 0, right: 16, bottom: 0),
          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);
                });
              }
            },
          ),
        ),
      )
    );
  }

  Widget _buildLanguage() {
    final l = AppLocalizations.of(context);
    return SizedBox(
      width: double.infinity,
      child: Card(
        margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
        color: _cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: ListTile(
          contentPadding: const EdgeInsets.only(left: 16, top: 0, right: 16, bottom: 0),
          title: Text(l.language),
          trailing: DropdownButton<String?>(
            value: _selectedLocaleTag,
            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;
              });
            },
          ),
        ),
      )
    );
  }

}