ソースコード 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-09-18

pubspec.yaml

name: wheellotterymachine
description: "wheellotterymachine"
# 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.0.5+31

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.2.2
  flutter_tts: ^4.2.3
  audioplayers: ^6.5.1
  google_mobile_ads: ^6.0.0
  flutter_localizations:
    sdk: flutter
  intl: ^0.20.2               #flutter gen-l10n

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'

# 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:
    - assets/image/
    - assets/sfx/

  # 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:roulettewheeleurope/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 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/frame_painter.dart

import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart';

class FramePainter extends CustomPainter {
  final ui.Image frame;
  FramePainter(this.frame);
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    // 1. フレームの短辺を基準に正方形にクロップ
    final side = frame.width < frame.height ? frame.width : frame.height;
    final src = Rect.fromLTWH(
      (frame.width - side) / 2.0,
      (frame.height - side) / 2.0,
      side.toDouble(),
      side.toDouble(),
    );
    // 2. 描画先も正方形に制限
    final length = size.shortestSide;
    final dx = (size.width - length) / 2.0;
    final dy = (size.height - length) / 2.0;
    final dst = Rect.fromLTWH(dx, dy, length, length);
    canvas.drawImageRect(frame, src, dst, paint);
  }
  @override
  bool shouldRepaint(covariant FramePainter oldDelegate) {
    return oldDelegate.frame != frame;
  }
}

lib/main.dart

import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'l10n/app_localizations.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'package:wheellotterymachine/ad_manager.dart';
import 'package:wheellotterymachine/ad_banner_widget.dart';
import 'package:wheellotterymachine/models.dart';
import 'package:wheellotterymachine/settings_page.dart';
import 'package:wheellotterymachine/frame_painter.dart';

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

class WheelLotteryApp extends StatefulWidget {
  const WheelLotteryApp({super.key});
  @override
  State<WheelLotteryApp> createState() => _WheelLotteryAppState();
}

class _WheelLotteryAppState extends State<WheelLotteryApp> {
  late Future<AppBootstrap> _bootstrap;
  @override
  void initState() {
    super.initState();
    _bootstrap = AppBootstrap.load();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<AppBootstrap>(
      future: _bootstrap,
      builder: (context, snap) {
        if (!snap.hasData) {
          return MaterialApp(
            debugShowCheckedModeBanner: false,
            theme: ThemeData.light(),
            darkTheme: ThemeData.dark(),
            home: const Scaffold(body: Center(child: CircularProgressIndicator())),
          );
        }
        final boot = snap.data!;
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          title: 'Wheel Lottery Machine',
          supportedLocales: AppLocalizations.supportedLocales,
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          locale: _localeFromTag(boot.settings.localeLanguage),
          theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true),
          darkTheme: ThemeData.dark(useMaterial3: true),
          themeMode: () {
            switch (boot.settings.themeNumber) {
              case 2:
                return ThemeMode.dark;
              case 1:
                return ThemeMode.light;
              default:
                return ThemeMode.system;
            }
          }(),
          home: HomePage(
            pref: boot.pref,
            settings: boot.settings,
            itemStates: boot.items,
            onThemeChanged: (n) async {
              setState(() {
                boot.settings.themeNumber = n;
              });
              await boot.settings.save(boot.pref);
            },
            onLocaleChanged: (tag) async {
              setState(() {
                boot.settings.localeLanguage = tag ?? '';
              });
              await boot.settings.save(boot.pref);
            },
          ),
        );
      },
    );
  }
}

Locale? _localeFromTag(String tag) {
  if (tag.isEmpty) return null;
  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);
}

class HomePage extends StatefulWidget {
  final SharedPreferences pref;
  final Settings settings;
  final List<ItemState> itemStates;
  final ValueChanged<int> onThemeChanged;
  final ValueChanged<String?> onLocaleChanged;
  const HomePage({super.key, required this.pref, required this.settings, required this.itemStates, required this.onThemeChanged, required this.onLocaleChanged});
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin {
  late List<ui.Image> _decodedFrames = [];
  final List<String> _balls = const [
    'assets/image/ball_gold.png',
    'assets/image/ball_silver.png',
    'assets/image/ball_purple.png',
    'assets/image/ball_blue.png',
    'assets/image/ball_green.png',
    'assets/image/ball_yellow.png',
    'assets/image/ball_red.png',
    'assets/image/ball_white.png',
  ];
  final Map<int, Offset> _pos = const {
    95: Offset(440, 540),
    96: Offset(457, 558),
    97: Offset(474, 589),
    98: Offset(492, 630),
    99: Offset(508, 620),
    100: Offset(527, 614),
    101: Offset(547, 616),
    102: Offset(567, 627),
    103: Offset(586, 651),
    104: Offset(601, 647),
    105: Offset(617, 650),
    106: Offset(634, 665),
    107: Offset(646, 670),
    108: Offset(657, 680),
    109: Offset(669, 709),
    110: Offset(680, 710),
  };
  late AdManager _adManager;
  final _audio = AudioPlayer();
  final _tts = FlutterTts();
  late AnimationController _controller;
  bool _busy = false;
  int _frame = 0;
  int _choice = 0;
  String _result = '';
  bool _showResult = false;
  late List<ItemState> _items;
  late Settings _settings;
  Offset? _lastBallNormPos; // in 900x900 machine coordinates
  bool _isReady = false;
  int _readyDotCount = 0;
  Timer? _readyTimer;

  @override
  void initState() {
    super.initState();
    _adManager = AdManager();
    _items = widget.itemStates.map((e) => e.copy()).toList();
    _settings = widget.settings;
    _setupTts();
    _adManager = AdManager();
    // Banner is loaded adaptively after layout via _updateBannerForWidth
    // Boost image cache capacity to reduce frame evictions
    imageCache.maximumSizeBytes = 512 * 1024 * 1024; // 512MB
    imageCache.maximumSize = 1000;
    _controller = AnimationController(vsync: this);
    _controller.addListener(() {
      final progress = _controller.value;
      final rawIdx = (progress * 120).floor().clamp(0, 119);
      final step = _frameStep();
      final dispCount = (120 / step).round();
      final dispIdxN = (progress * dispCount).floor().clamp(0, dispCount - 1);
      final dispIdx = (dispIdxN * step).clamp(0, 119);
      var speakNow = false;
      setState(() {
        _frame = dispIdx;
        if (!_showResult && rawIdx >= 110) {
          _showResult = true;
          speakNow = true;
        }
      });
      if (speakNow && _settings.speechNumber == 1 && _result.isNotEmpty) {
        _speakResult();
      }
    });
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        setState(() {
          _busy = false;
          _frame = 0;
        });
      }
    });
    // ローディング中に点をアニメーション
    _readyTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
      if (!_isReady) {
        setState(() {
          _readyDotCount = (_readyDotCount + 1) % 4; // 0~3 を繰り返す
        });
      }
    });
    WidgetsBinding.instance.addPostFrameCallback((_) async {
      _decodedFrames = await loadAllFrames();
      setState(() {
        _isReady = true;
      });
    });
  }

  @override
  void dispose() {
    _readyTimer?.cancel();
    _controller.dispose();
    _adManager.dispose();
    _audio.dispose();
    super.dispose();
  }

  Future<List<ui.Image>> loadAllFrames() async {
    final List<ui.Image> decodedFrames = [];
    for (int i = 0; i < 120; i++) {
      final path = 'assets/image/machine${(i + 1).toString().padLeft(3, '0')}.webp';
      final data = await rootBundle.load(path);
      final codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
      final frame = await codec.getNextFrame();
      decodedFrames.add(frame.image);
    }
    return decodedFrames;
  }

  Future<void> _speakResult() async {
    try {
      await _tts.setVolume(_settings.volumeSpeech.clamp(0, 10) / 10);
      await _tts.speak(_result);
    } catch (_) {}
  }

  Future<void> _setupTts() async {
    await _tts.setSpeechRate(0.5);
    await _tts.setVolume(_settings.volumeSpeech.clamp(0, 10) / 10);
    if (_settings.speechVoice.isNotEmpty) {
      try {
        if (_settings.speechLocale.isNotEmpty) {
          await _tts.setVoice({'name': _settings.speechVoice, 'locale': _settings.speechLocale});
        } else {
          await _tts.setVoice({'name': _settings.speechVoice});
        }
      } catch (_) {}
    }
  }

  @override
  Widget build(BuildContext context) {
    if (!_isReady) {
      final dots = "." * _readyDotCount;
      return Scaffold(
        backgroundColor: const Color(0xFF4CAF50),
        body: Center(
          child: Text(
            "$dots loading $dots",
            style: const TextStyle(fontSize: 24, color: Colors.white),
          ),
        ),
      );
    }
    return Scaffold(
      backgroundColor: Colors.green,
      body: SafeArea(
        child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
          Row(children: [
            const Spacer(),
            Padding(
              padding: const EdgeInsets.only(right: 10),
              child: IconButton(
                onPressed: _busy ? null : _onSetting,
                tooltip: AppLocalizations.of(context).setting,
                icon: Icon(Icons.settings, color: Colors.white.withValues(alpha: 0.85)),
              ),
            ),
          ]),
          const SizedBox(height: 5),
          Expanded(child: LayoutBuilder(builder: (context, c) {
            final box = min(c.maxWidth, c.maxHeight);
            final ml = (c.maxWidth - box) / 2;
            final mt = (c.maxHeight - box) / 2;
            final bs = box / 25;
            final op = (_frame >= 95 || _showResult) ? 1.0 : 0.0;
            Offset? p = _pos[_frame];
            if (p == null && _frame >= 95) {
              final keys = _pos.keys.where((k) => k <= _frame).toList()..sort();
              if (keys.isNotEmpty) p = _pos[keys.last];
            }
            if (p != null) {
              _lastBallNormPos = p;
            }
            // When animation finished, keep showing last position
            final useP = p ?? (_showResult ? _lastBallNormPos : null);
            double? x, y;
            if (useP != null) {
              final px = box / 900.0;
              final h = bs / 2;
              x = ml + px * useP.dx - h;
              y = mt + px * useP.dy - h;
            }
            final idx = _frame.clamp(0, 119).toInt();
            final dpr = MediaQuery.of(context).devicePixelRatio;
            final targetWidthPx = max(1, (box * dpr).round());
            return Stack(children: [
              _frameImage(idx,targetWidthPx),
              if (x != null && y != null)
                Positioned(
                  left: x,
                  top: y,
                  width: bs,
                  height: bs,
                  child: Opacity(
                    opacity: op,
                    child: Image.asset(
                      _balls[_choice],
                      cacheWidth: max(1, (bs * dpr).round()),
                      filterQuality: FilterQuality.low,
                    ),
                  ),
                ),
              // Result text at bottom inside the machine area
              Positioned(
                left: ml,
                top: mt,
                width: box,
                height: box,
                child: IgnorePointer(
                  child: AnimatedOpacity(
                    opacity: _showResult ? 1.0 : 0.0,
                    duration: const Duration(milliseconds: 300),
                    child: Align(
                      alignment: Alignment.bottomCenter,
                      child: Padding(
                        padding: const EdgeInsets.only(bottom: 0),
                        child: Text(
                          _result,
                          textAlign: TextAlign.center,
                          style: const TextStyle(
                            color: Colors.white,
                            fontSize: 24,
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ]);
          })),
          const SizedBox(height: 12),
          Center(
            child: Opacity(
              opacity: _busy ? 0.4 : 1.0,
              child: ElevatedButton(
                onPressed: _busy ? null : _onStart,
                style: ElevatedButton.styleFrom(
                  shape: const CircleBorder(),
                  fixedSize: const Size(130, 130),
                  backgroundColor: Colors.white.withValues(alpha: 0.25),
                  foregroundColor: Colors.white,
                  elevation: 0,
                ),
                child: FittedBox(
                  fit: BoxFit.scaleDown,
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Text(
                      AppLocalizations.of(context).drawLot,
                      textAlign: TextAlign.center,
                      style: const TextStyle(color: Colors.white,fontSize: 48),
                    ),
                  ),
                ),
              ),
            ),
          ),
        ]),
      ),
      bottomNavigationBar: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          SizedBox(height: 20),
          AdBannerWidget(adManager: _adManager),
        ],
      ),
    );
  }

  Widget _frameImage(int idx, int targetWidthPx) {
    return Center(
      child: SizedBox(
        width: targetWidthPx.toDouble(),
        height: targetWidthPx.toDouble(),
        child: CustomPaint(
          painter: FramePainter(_decodedFrames[idx]),
        ),
      ),
    );
  }

  Future<void> _onStart() async {
    if (_busy) {
      return;
    }
    setState(() {
      _busy = true;
      _showResult = false;
      _frame = 0;
      _lastBallNormPos = null;
    });
    final sum = _items.fold<int>(0, (p, e) => p + e.qty);
    if (sum == 0) {
      setState(() {
        _result = AppLocalizations.of(context).empty;
        _showResult = true;
        _busy = false;
      });
      return;
    }
    final rnd = Random();
    var remain = rnd.nextInt(sum);
    var choice = 0;
    for (int i = 0; i < _items.length; i++) {
      final q = _items[i].qty;
      if (q == 0) {
        continue;
      }
      if (remain >= q) {
        remain -= q;
      } else {
        choice = i;
        _items[i].qty = max(0, _items[i].qty - 1);
        break;
      }
    }
    _choice = choice;
    _result = _items[_choice].name;
    await AppBootstrap.saveItemStates(widget.pref, _items);
    final vol = _settings.volumeSpin.clamp(0, 10) / 10;
    if (vol > 0) {
      try {
        await _audio.setSource(AssetSource('sfx/garagara2.wav'));
        await _audio.setVolume(vol);
        await _audio.setPlaybackRate((_settings.shortNumber + 1).toDouble());
        unawaited(_audio.resume());
      } catch (_) {}
    }
    final msPerFrame = _settings.animationSpeed.clamp(1, 1000);
    _controller
      ..duration = Duration(milliseconds: msPerFrame * _displayedFrameCount())
      ..reset();
    _controller.forward();
  }

  int _frameStep() {
    switch (_settings.shortNumber.clamp(0, 4)) {
      case 1:
        return 2; // 60 frames
      case 2:
        return 3; // 40 frames
      case 3:
        return 4; // 30 frames
      case 4:
        return 6; // 20 frames
      default:
        return 1; // 120 frames
    }
  }

  int _displayedFrameCount() {
    final step = _frameStep();
    return (120 / step).round();
  }

  Future<void> _onSetting() async {
    if (_busy) {
      return;
    }
    if (_settings.pin.isNotEmpty) {
      final ok = await _promptPin();
      if (!ok) return;
    }
    final res = await Navigator.of(context).push<SettingsResult>(MaterialPageRoute(
        builder: (_) => SettingsPage(pref: widget.pref, items: _items.map((e) => e.copy()).toList(), settings: _settings.clone())
    ));
    if (res != null) {
      setState(() {
        _items = res.items;
        _settings = res.settings;
      });
      await AppBootstrap.saveItemStates(widget.pref, _items);
      await _settings.save(widget.pref);
      await _setupTts();
      // notify app to rebuild with new theme/locale
      widget.onThemeChanged(_settings.themeNumber);
      widget.onLocaleChanged(_settings.localeLanguage.isEmpty ? null : _settings.localeLanguage);
    }
  }

  Future<bool> _promptPin() async {
    final c = TextEditingController();
    final ok = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('PIN'),
        content: TextField(controller: c, keyboardType: TextInputType.number, obscureText: true, decoration: const InputDecoration(hintText: 'Enter PIN')),
        actions: [TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('OK'))],
      ),
    );
    if (ok != true) {
      return false;
    }
    return c.text == _settings.pin;
  }
}

lib/models.dart

import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';

class ConstValue {
  static const String settings = 'settings';
  static const String pin = 'pin';
  static const String animationSpeed = 'animationSpeed';
  static const String speechNumber = 'speechNumber';
  static const String speechVoice = 'speechVoice';
  static const String speechLocale = 'speechLocale';
  static const String shortNumber = 'shortNumber';
  static const String volumeSpin = 'volumeSpin';
  static const String volumeSpeech = 'volumeSpeech';
  static const String themeNumber = 'themeNumber';
  static const String localeLanguage = 'localeLanguage';
  static const String itemName1 = 'itemName1';
  static const String itemName2 = 'itemName2';
  static const String itemName3 = 'itemName3';
  static const String itemName4 = 'itemName4';
  static const String itemName5 = 'itemName5';
  static const String itemName6 = 'itemName6';
  static const String itemName7 = 'itemName7';
  static const String itemName8 = 'itemName8';
  static const String itemQty1 = 'itemQty1';
  static const String itemQty2 = 'itemQty2';
  static const String itemQty3 = 'itemQty3';
  static const String itemQty4 = 'itemQty4';
  static const String itemQty5 = 'itemQty5';
  static const String itemQty6 = 'itemQty6';
  static const String itemQty7 = 'itemQty7';
  static const String itemQty8 = 'itemQty8';
}

class ItemState {
  String name;
  int qty;
  ItemState({required this.name, required this.qty});
  ItemState copy() => ItemState(name: name, qty: qty);
}

class Settings {
  String pin;
  int animationSpeed; // 1..1000 ms per frame
  int speechNumber; // 0/1
  String speechVoice;
  String speechLocale;
  int shortNumber; // 0..4
  int volumeSpin; // 0..10
  int volumeSpeech; // 0..10
  int themeNumber; // 0 system, 1 light, 2 dark
  String localeLanguage; // e.g. 'ja', '' for system

  Settings({
    this.pin = '',
    this.animationSpeed = 25,
    this.speechNumber = 1,
    this.speechVoice = '',
    this.speechLocale = '',
    this.shortNumber = 0,
    this.volumeSpin = 10,
    this.volumeSpeech = 10,
    this.themeNumber = 0,
    this.localeLanguage = '',
  });

  Settings clone() => Settings(
        pin: pin,
        animationSpeed: animationSpeed,
        speechNumber: speechNumber,
        speechVoice: speechVoice,
        speechLocale: speechLocale,
        shortNumber: shortNumber,
        volumeSpin: volumeSpin,
        volumeSpeech: volumeSpeech,
        themeNumber: themeNumber,
        localeLanguage: localeLanguage,
      );

  Future<void> save(SharedPreferences pref) async {
    await pref.setString(ConstValue.pin, pin);
    await pref.setInt(ConstValue.animationSpeed, animationSpeed);
    await pref.setInt(ConstValue.speechNumber, speechNumber);
    await pref.setString(ConstValue.speechVoice, speechVoice);
    await pref.setString(ConstValue.speechLocale, speechLocale);
    await pref.setInt(ConstValue.shortNumber, shortNumber);
    await pref.setInt(ConstValue.volumeSpin, volumeSpin);
    await pref.setInt(ConstValue.volumeSpeech, volumeSpeech);
    await pref.setInt(ConstValue.themeNumber, themeNumber);
    await pref.setString(ConstValue.localeLanguage, localeLanguage);
  }

  static Settings fromPrefs(SharedPreferences pref) {
    final s = Settings();
    s.pin = pref.getString(ConstValue.pin) ?? '';
    s.animationSpeed = (pref.getInt(ConstValue.animationSpeed) ?? 25).clamp(1, 1000);
    s.speechNumber = pref.getInt(ConstValue.speechNumber) ?? 1;
    s.speechVoice = pref.getString(ConstValue.speechVoice) ?? '';
    s.speechLocale = pref.getString(ConstValue.speechLocale) ?? '';
    s.shortNumber = pref.getInt(ConstValue.shortNumber) ?? 0;
    s.volumeSpin = pref.getInt(ConstValue.volumeSpin) ?? 10;
    s.volumeSpeech = pref.getInt(ConstValue.volumeSpeech) ?? 10;
    s.themeNumber = (pref.getInt(ConstValue.themeNumber) ?? 0).clamp(0, 2);
    s.localeLanguage = pref.getString(ConstValue.localeLanguage) ?? '';
    return s;
  }
}

class AppBootstrap {
  final SharedPreferences pref;
  final Settings settings;
  final List<ItemState> items;
  AppBootstrap(this.pref, this.settings, this.items);

  static Future<AppBootstrap> load() async {
    final pref = await SharedPreferences.getInstance();
    final settings = Settings.fromPrefs(pref);
    final items = loadItemStates(pref);
    return AppBootstrap(pref, settings, items);
  }

  static List<ItemState> loadItemStates(SharedPreferences pref) {
    final items = [
      ItemState(name: pref.getString(ConstValue.itemName1) ?? '', qty: pref.getInt(ConstValue.itemQty1) ?? 0),
      ItemState(name: pref.getString(ConstValue.itemName2) ?? '', qty: pref.getInt(ConstValue.itemQty2) ?? 0),
      ItemState(name: pref.getString(ConstValue.itemName3) ?? '', qty: pref.getInt(ConstValue.itemQty3) ?? 0),
      ItemState(name: pref.getString(ConstValue.itemName4) ?? '', qty: pref.getInt(ConstValue.itemQty4) ?? 0),
      ItemState(name: pref.getString(ConstValue.itemName5) ?? '', qty: pref.getInt(ConstValue.itemQty5) ?? 0),
      ItemState(name: pref.getString(ConstValue.itemName6) ?? '', qty: pref.getInt(ConstValue.itemQty6) ?? 0),
      ItemState(name: pref.getString(ConstValue.itemName7) ?? '', qty: pref.getInt(ConstValue.itemQty7) ?? 0),
      ItemState(name: pref.getString(ConstValue.itemName8) ?? '', qty: pref.getInt(ConstValue.itemQty8) ?? 0),
    ];
    if (items.every((e) => e.name.isEmpty)) {
      items[0]
        ..name = 'Grand Prize'
        ..qty = 1;
      items[1]
        ..name = 'Second Prize'
        ..qty = 1;
      items[2]
        ..name = 'Premium Prize'
        ..qty = 2;
      items[3]
        ..name = 'Prize A'
        ..qty = 2;
      items[4]
        ..name = 'Prize B'
        ..qty = 3;
      items[5]
        ..name = 'Prize C'
        ..qty = 10;
      items[6]
        ..name = 'Consolation Prize'
        ..qty = 100;
      items[7]
        ..name = 'Thank You Prize'
        ..qty = 500;
      saveItemStates(pref, items);
    }
    return items;
  }

  static Future<void> saveItemStates(SharedPreferences pref, List<ItemState> items) async {
    await pref.setString(ConstValue.itemName1, items[0].name);
    await pref.setInt(ConstValue.itemQty1, items[0].qty);
    await pref.setString(ConstValue.itemName2, items[1].name);
    await pref.setInt(ConstValue.itemQty2, items[1].qty);
    await pref.setString(ConstValue.itemName3, items[2].name);
    await pref.setInt(ConstValue.itemQty3, items[2].qty);
    await pref.setString(ConstValue.itemName4, items[3].name);
    await pref.setInt(ConstValue.itemQty4, items[3].qty);
    await pref.setString(ConstValue.itemName5, items[4].name);
    await pref.setInt(ConstValue.itemQty5, items[4].qty);
    await pref.setString(ConstValue.itemName6, items[5].name);
    await pref.setInt(ConstValue.itemQty6, items[5].qty);
    await pref.setString(ConstValue.itemName7, items[6].name);
    await pref.setInt(ConstValue.itemQty7, items[6].qty);
    await pref.setString(ConstValue.itemName8, items[7].name);
    await pref.setInt(ConstValue.itemQty8, items[7].qty);
  }

  static int weightedChoice(List<ItemState> items, Random rng) {
    final sum = items.fold<int>(0, (p, e) => p + e.qty);
    if (sum == 0) return 0;
    var remain = rng.nextInt(sum);
    for (int i = 0; i < items.length; i++) {
      final q = max(0, items[i].qty);
      if (q == 0) continue;
      if (remain >= q) {
        remain -= q;
      } else {
        return i;
      }
    }
    return 0;
  }
}

lib/settings_page.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'l10n/app_localizations.dart';

import 'package:wheellotterymachine/models.dart';
import 'package:wheellotterymachine/ad_manager.dart';
import 'package:wheellotterymachine/ad_banner_widget.dart';

class SettingsResult {
  final List<ItemState> items;
  final Settings settings;
  SettingsResult(this.items, this.settings);
}

class SettingsPage extends StatefulWidget {
  final SharedPreferences pref;
  final List<ItemState> items;
  final Settings settings;
  const SettingsPage({super.key, required this.pref, required this.items, required this.settings});

  @override
  State<SettingsPage> createState() => _SettingsPageState();
}

class _SettingsPageState extends State<SettingsPage> {
  late final TextEditingController _pinCtrl;
  late final TextEditingController _speedCtrl;
  late final List<TextEditingController> _nameCtrls;
  late final List<TextEditingController> _qtyCtrls;
  late int _shortNumber;
  late int _volumeSpin;
  late int _volumeSpeech;
  late bool _speechEnabled;
  late int _themeNumber; // 0 system, 1 light, 2 dark
  late String _speechVoice;
  late String _speechLocale;
  List<_VoiceOption> _voices = [];
  late AdManager _adManager;
  String? _selectedLocaleTag;
  final List<String> _ballAssets = const [
    'assets/image/ball_gold.png',
    'assets/image/ball_silver.png',
    'assets/image/ball_purple.png',
    'assets/image/ball_blue.png',
    'assets/image/ball_green.png',
    'assets/image/ball_yellow.png',
    'assets/image/ball_red.png',
    'assets/image/ball_white.png',
  ];
  final List<String> _ballLabels = const ['GOLD', 'SILVER', 'PURPLE', 'BLUE', 'GREEN', 'YELLOW', 'RED', 'WHITE'];

  @override
  void initState() {
    super.initState();
    _adManager = AdManager();
    _pinCtrl = TextEditingController(text: widget.settings.pin);
    _speedCtrl = TextEditingController(text: widget.settings.animationSpeed.toString());
    _nameCtrls = List.generate(8, (i) => TextEditingController(text: widget.items[i].name));
    _qtyCtrls = List.generate(8, (i) => TextEditingController(text: widget.items[i].qty == 0 ? '' : widget.items[i].qty.toString()));
    _shortNumber = widget.settings.shortNumber;
    _volumeSpin = widget.settings.volumeSpin;
    _volumeSpeech = widget.settings.volumeSpeech;
    _speechEnabled = widget.settings.speechNumber == 1;
    _themeNumber = widget.settings.themeNumber;
    _speechVoice = widget.settings.speechVoice;
    _speechLocale = widget.settings.speechLocale;
    _selectedLocaleTag = widget.settings.localeLanguage.isEmpty ? null : widget.settings.localeLanguage;
    _loadVoices();
  }

  Future<void> _loadVoices() async {
    final tts = FlutterTts();
    try {
      final vs = await tts.getVoices;
      final List<_VoiceOption> voiceOptions = [];
      if (vs is List) {
        for (final v in vs) {
          if (v is Map && v['name'] is String && v['locale'] is String) {
            voiceOptions.add(_VoiceOption(v['locale'] as String, v['name'] as String));
          }
        }
      }
      voiceOptions.sort((a, b) => a.label.compareTo(b.label));
      setState(() => _voices = voiceOptions);
    } catch (_) {}
  }

  @override
  void dispose() {
    _pinCtrl.dispose();
    _speedCtrl.dispose();
    for (final c in _nameCtrls) {
      c.dispose();
    }
    for (final c in _qtyCtrls) {
      c.dispose();
    }
    _adManager.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final l = AppLocalizations.of(context);
    return Scaffold(
      appBar: AppBar(
        automaticallyImplyLeading: false,
        leading: IconButton(
          icon: const Icon(Icons.close),
          tooltip: l.close,
          onPressed: () => Navigator.of(context).pop(),
        ),
        title: null,
        actions: [
          Padding(
            padding: const EdgeInsets.only(right: 10),
            child: IconButton(
              onPressed: _apply,
              tooltip: l.apply,
              icon: const Icon(Icons.check),
            ),
          ),
        ],
      ),
      body: GestureDetector(
        onTap: () => FocusScope.of(context).unfocus(),
        child: SafeArea(
          child: Column(
            children: [
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.fromLTRB(20, 20, 20, 50),
                  children: [
                    _buildItemsTable(),
                    const Divider(height: 40, thickness: 1),
                    _buildPin(),
                    const Divider(height: 40, thickness: 1),
                    _buildSpeed(),
                    const SizedBox(height: 10),
                    _buildShort(),
                    const Divider(height: 40, thickness: 1),
                    _buildVolumes(),
                    const Divider(height: 40, thickness: 1),
                    _buildSpeech(),
                    const Divider(height: 40, thickness: 1),
                    _buildLanguage(),
                    const Divider(height: 40, thickness: 1),
                    _buildTheme(),
                    const Divider(height: 40, thickness: 1),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
      bottomNavigationBar: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          SizedBox(height: 10),
          AdBannerWidget(adManager: _adManager),
        ],
      ),
    );
  }

  Widget _buildItemsTable() {
    final l = AppLocalizations.of(context);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(l.resultsAndQuantity),
        const SizedBox(height: 8),
        Table(
          columnWidths: const {
            0: FixedColumnWidth(24),
            1: IntrinsicColumnWidth(),
            2: FlexColumnWidth(1),
            3: FixedColumnWidth(64),
          },
          defaultVerticalAlignment: TableCellVerticalAlignment.middle,
          children: List.generate(8, (i) {
            return TableRow(children: [
              Padding(padding: const EdgeInsets.all(4), child: Image.asset(_ballAssets[i], width: 20, height: 20)),
              Padding(padding: const EdgeInsets.all(4), child: Text(_ballLabels[i])),
              Padding(
                padding: const EdgeInsets.all(4),
                child: TextField(
                  controller: _nameCtrls[i],
                  decoration: InputDecoration(isDense: true, hintText: 'Name'),
                  inputFormatters: [LengthLimitingTextInputFormatter(50)],
                ),
              ),
              Padding(
                padding: const EdgeInsets.all(4),
                child: SizedBox(
                  width: 60,
                  child: TextField(
                    controller: _qtyCtrls[i],
                    keyboardType: TextInputType.number,
                    inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                    decoration: const InputDecoration(isDense: true),
                  ),
                ),
              ),
            ]);
          }),
        ),
      ],
    );
  }

  Widget _buildPin() {
    final l = AppLocalizations.of(context);
    return Column(crossAxisAlignment: CrossAxisAlignment.start,children: [
      Row(
        children: [
          Text(l.pin),
          const SizedBox(width: 12),
          Expanded(
            child: TextField(
              controller: _pinCtrl,
              obscureText: true,
              decoration: const InputDecoration(isDense: true),
              keyboardType: TextInputType.number,
              inputFormatters: [FilteringTextInputFormatter.digitsOnly],
            ),
          ),
        ],
      ),
      const SizedBox(height: 6),
      Text(l.pinNote, style: const TextStyle(fontSize: 12, color: Colors.grey)),
    ]);
  }

  Widget _buildSpeed() {
    final l = AppLocalizations.of(context);
    return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
      Row(
        children: [
          Text(l.animationSpeed),
          const SizedBox(width: 12),
          Expanded(
            child: TextField(
              controller: _speedCtrl,
              keyboardType: TextInputType.number,
              inputFormatters: [FilteringTextInputFormatter.digitsOnly],
              decoration: const InputDecoration(isDense: true),
            ),
          ),
        ],
      ),
    ]);
  }

  Widget _buildShort() {
    final l = AppLocalizations.of(context);
    return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
      Row(children: [
        Text(l.speedUp),
        Expanded(
          child: Slider(
            min: 0,
            max: 4,
            divisions: 4,
            value: _shortNumber.toDouble(),
            label: _shortNumber.toString(),
            onChanged: (v) => setState(() => _shortNumber = v.round()),
          ),
        ),
        Text(
          _shortNumber.toInt().toString().padLeft(2, '0'),
          style: Theme.of(context).textTheme.titleMedium,
        ),
      ]),
    ]);
  }

  Widget _buildVolumes() {
    final l = AppLocalizations.of(context);
    return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
      Row(children: [
        Expanded(child: Text(l.volumeSpin)),
        SizedBox(
          width: 220,
          child: Slider(
            min: 0,
            max: 10,
            divisions: 10,
            value: _volumeSpin.toDouble(),
            label: _volumeSpin.toString(),
            onChanged: (v) => setState(() => _volumeSpin = v.round()),
          ),
        ),
        Text(
          _volumeSpin.toInt().toString().padLeft(2, '0'),
          style: Theme.of(context).textTheme.titleMedium,
        ),
      ]),
      Row(children: [
        Expanded(child: Text(l.volumeSpeech)),
        SizedBox(
          width: 220,
          child: Slider(
            min: 0,
            max: 10,
            divisions: 10,
            value: _volumeSpeech.toDouble(),
            label: _volumeSpeech.toString(),
            onChanged: (v) => setState(() => _volumeSpeech = v.round()),
          ),
        ),
        Text(
          _volumeSpeech.toInt().toString().padLeft(2, '0'),
          style: Theme.of(context).textTheme.titleMedium,
        ),
      ]),
    ]);
  }

  Widget _buildSpeech() {
    final l = AppLocalizations.of(context);
    return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
      SwitchListTile(
        value: _speechEnabled,
        onChanged: (v) => setState(() => _speechEnabled = v),
        title: Text(l.readOutResults),
        contentPadding: const EdgeInsets.symmetric(horizontal: 0),
      ),
      if (_voices.isNotEmpty)
        DropdownButtonFormField<String>(
          initialValue: () {
            String? sel;
            for (final o in _voices) {
              if (o.name == _speechVoice && o.locale == _speechLocale) {
                sel = o.id;
                break;
              }
            }
            sel ??= _voices.first.id;
            return sel;
          }(),
          items: _voices.map((o) => DropdownMenuItem<String>(value: o.id, child: Text(o.label))).toList(),
          onChanged: (v) {
            if (v == null) return;
            final idx = v.indexOf('|');
            if (idx > 0) {
              setState(() {
                _speechLocale = v.substring(0, idx);
                _speechVoice = v.substring(idx + 1);
              });
            }
          },
          decoration: InputDecoration(labelText: l.voice),
        ),
    ]);
  }

  Widget _buildLanguage() {
    final l = AppLocalizations.of(context);
    const languageOptions = {
      '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': '繁體中文',
    };
    return Column(crossAxisAlignment: CrossAxisAlignment.start,children: [
      ListTile(
        title: Text(l.language),
        contentPadding: const EdgeInsets.symmetric(horizontal: 0),
        trailing: DropdownButton<String?>(
          value: _selectedLocaleTag,
          hint: Text(l.systemDefault),
          items: [
            DropdownMenuItem<String?>(value: null, child: Text(l.systemDefault)),
            ...languageOptions.entries.map((e) => DropdownMenuItem<String?>(value: e.key, child: Text(e.value))),
          ],
          onChanged: (value) {
            setState(() {
              _selectedLocaleTag = value;
            });
          },
        ),
      ),
    ]);
  }

  Widget _buildTheme() {
    final l = AppLocalizations.of(context);
    return Column(crossAxisAlignment: CrossAxisAlignment.start,children: [
      ListTile(
        title: Text(l.theme),
        contentPadding: const EdgeInsets.symmetric(horizontal: 0),
        trailing: DropdownButton<int>(
          value: _themeNumber,
          items: [
            DropdownMenuItem(value: 0, child: Text(l.systemDefault)),
            DropdownMenuItem(value: 1, child: Text(l.lightTheme)),
            DropdownMenuItem(value: 2, child: Text(l.darkTheme)),
          ],
          onChanged: (v) => setState(() => _themeNumber = v ?? 0),
        ),
      ),
    ]);
  }

  void _apply() {
    final items = List<ItemState>.generate(8, (i) {
      final name = _nameCtrls[i].text.trim();
      final qty = int.tryParse(_qtyCtrls[i].text.trim().isEmpty ? '0' : _qtyCtrls[i].text.trim()) ?? 0;
      return ItemState(name: name, qty: qty);
    });
    int speed = int.tryParse(_speedCtrl.text.trim().isEmpty ? '10' : _speedCtrl.text.trim()) ?? 10;
    speed = speed.clamp(1, 1000);
    final settings = Settings(
      pin: _pinCtrl.text.trim(),
      animationSpeed: speed,
      speechNumber: _speechEnabled ? 1 : 0,
      speechVoice: _speechVoice,
      speechLocale: _speechLocale,
      shortNumber: _shortNumber,
      volumeSpin: _volumeSpin,
      volumeSpeech: _volumeSpeech,
      themeNumber: _themeNumber,
      localeLanguage: _selectedLocaleTag ?? '',
    );
    Navigator.of(context).pop(SettingsResult(items, settings));
  }
}

class _VoiceOption {
  final String locale;
  final String name;
  const _VoiceOption(this.locale, this.name);
  String get id => '$locale|$name';
  String get label => '$locale $name';
}