ソースコード source code

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

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

下記コードの最終ビルド日: 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';
}