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

pubspec.yaml

name: fortuneslip
description: "FortuneSlip"
# 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.2+18

environment:
  sdk: ^3.9.2

# 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
  flutter_localizations:
    sdk: flutter
  shared_preferences: ^2.3.2
  flutter_tts: ^4.0.2
  google_mobile_ads: ^6.0.0


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_launcher_icons: ^0.14.4    #flutter pub run flutter_launcher_icons
  flutter_native_splash: ^2.4.0     #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

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'

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

# The following section is specific to Flutter packages.
flutter:

  # 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
  generate: true
  assets:
    - assets/image/
    - assets/image/fortune/


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

  # 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:fortuneslip/ad_manager.dart';

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

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

lib/ad_manager.dart

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

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

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

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

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

  BannerAd? get bannerAd => _bannerAd;

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

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

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

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

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

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

lib/fortune_data.dart

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

class FortuneItem {
  const FortuneItem({required this.rawName, required this.ratio});

  final String rawName;
  final int ratio;

  String get display {
    final index = rawName.indexOf(':');
    if (index == -1) {
      return rawName.trim();
    }
    return rawName.substring(0, index).trim();
  }

  String get reading {
    final index = rawName.indexOf(':');
    if (index == -1) {
      return rawName.trim();
    }
    return rawName.substring(index + 1).trim();
  }

  FortuneItem copyWith({String? rawName, int? ratio}) {
    return FortuneItem(
      rawName: rawName ?? this.rawName,
      ratio: ratio ?? this.ratio,
    );
  }
}

class FortuneSettings {
  FortuneSettings({
    required List<FortuneItem> items,
    required this.speakResult,
    required this.animationSpeed,
    required this.countdownTime,
    required this.themeMode,
    required this.locale,
    this.speechVoice = '',
    this.speechLocale = '',
  }) : items = List<FortuneItem>.from(items);

  final List<FortuneItem> items;
  final bool speakResult;
  final int animationSpeed;
  final int countdownTime;
  final ThemeMode themeMode;
  final Locale? locale;
  final String speechVoice;
  final String speechLocale;

  FortuneSettings copyWith({
    List<FortuneItem>? items,
    bool? speakResult,
    int? animationSpeed,
    int? countdownTime,
    ThemeMode? themeMode,
    Locale? locale,
    String? speechVoice,
    String? speechLocale,
  }) {
    return FortuneSettings(
      items: items ?? this.items,
      speakResult: speakResult ?? this.speakResult,
      animationSpeed: animationSpeed ?? this.animationSpeed,
      countdownTime: countdownTime ?? this.countdownTime,
      themeMode: themeMode ?? this.themeMode,
      locale: locale ?? this.locale,
      speechVoice: speechVoice ?? this.speechVoice,
      speechLocale: speechLocale ?? this.speechLocale,
    );
  }
}

class FortuneDefaults {
  static List<FortuneItem> forLocale(Locale? locale) {
    final code = locale?.languageCode;
    if (code == 'ja') {
      return _jaDefaults;
    }
    return _enDefaults;
  }

  static List<FortuneItem> get _enDefaults => const [
        FortuneItem(rawName: 'Great blessing', ratio: 1),
        FortuneItem(rawName: 'Blessing', ratio: 3),
        FortuneItem(rawName: 'Middle blessing', ratio: 5),
        FortuneItem(rawName: 'Small blessing', ratio: 5),
        FortuneItem(rawName: 'Uncertain luck', ratio: 5),
        FortuneItem(rawName: 'Curse', ratio: 1),
        FortuneItem(rawName: 'Great curse', ratio: 1),
      ];

  static List<FortuneItem> get _jaDefaults => const [
        FortuneItem(rawName: '大吉:だいきち', ratio: 1),
        FortuneItem(rawName: '吉:きち', ratio: 3),
        FortuneItem(rawName: '中吉:ちゅうきち', ratio: 5),
        FortuneItem(rawName: '小吉:しょうきち', ratio: 5),
        FortuneItem(rawName: '末吉:すえきち', ratio: 5),
        FortuneItem(rawName: '凶:きょう', ratio: 1),
        FortuneItem(rawName: '大凶:だいきょう', ratio: 1),
      ];
}

class FortuneStorage {
  static const _speechNumberKey = 'speechNumber';
  static const _speechVoiceKey = 'speechVoice';
  static const _speechLocaleKey = 'speechLocale';
  static const _animationSpeedKey = 'animationSpeed';
  static const _countdownTimeKey = 'countdownTime';
  static const _themeNumberKey = 'themeNumber';
  static const _localeLanguageKey = 'localeLanguage';

  Future<FortuneSettings> load() async {
    final prefs = await SharedPreferences.getInstance();
    final localeCode = prefs.getString(_localeLanguageKey) ?? '';
    final locale = localeCode.isEmpty ? null : Locale(localeCode);
    final items = <FortuneItem>[];
    for (var i = 1; i <= 7; i++) {
      final name = prefs.getString('itemName$i') ?? '';
      final ratio = prefs.getInt('itemRatio$i') ?? 0;
      items.add(FortuneItem(rawName: name, ratio: max(0, ratio)));
    }
    final allNamesEmpty = items.every((item) => item.rawName.isEmpty);
    final effectiveItems = allNamesEmpty
        ? FortuneDefaults.forLocale(locale ?? WidgetsBinding.instance.platformDispatcher.locale)
        : items;

    final speakResult = (prefs.getInt(_speechNumberKey) ?? 1) != 0;
    final speechVoice = prefs.getString(_speechVoiceKey) ?? '';
    final speechLocale = prefs.getString(_speechLocaleKey) ?? '';
    final animationSpeed = 4000;
    final countdownTime = (prefs.getInt(_countdownTimeKey) ?? 3).clamp(0, 9);
    final themeValue = prefs.getInt(_themeNumberKey);
    final themeMode = switch (themeValue) {
      0 => ThemeMode.light,
      1 => ThemeMode.dark,
      2 => ThemeMode.system,
      null => ThemeMode.system,
      _ => ThemeMode.system,
    };

    return FortuneSettings(
      items: effectiveItems,
      speakResult: speakResult,
      animationSpeed: animationSpeed,
      countdownTime: countdownTime,
      themeMode: themeMode,
      locale: locale,
      speechVoice: speechVoice,
      speechLocale: speechLocale,
    );
  }

  Future<void> save(FortuneSettings settings) async {
    final prefs = await SharedPreferences.getInstance();
    for (var i = 0; i < settings.items.length; i++) {
      final item = settings.items[i];
      final index = i + 1;
      await prefs.setString('itemName$index', item.rawName);
      await prefs.setInt('itemRatio$index', item.ratio);
    }
    await prefs.setInt(_speechNumberKey, settings.speakResult ? 1 : 0);
    if (settings.speechVoice.isEmpty && settings.speechLocale.isEmpty) {
      await prefs.remove(_speechVoiceKey);
      await prefs.remove(_speechLocaleKey);
    } else {
      await prefs.setString(_speechVoiceKey, settings.speechVoice);
      await prefs.setString(_speechLocaleKey, settings.speechLocale);
    }
    await prefs.setInt(_animationSpeedKey, 4000);
    await prefs.setInt(_countdownTimeKey, settings.countdownTime.clamp(0, 9));
    final themeValue = switch (settings.themeMode) {
      ThemeMode.light => 0,
      ThemeMode.dark => 1,
      ThemeMode.system => 2,
    };
    await prefs.setInt(_themeNumberKey, themeValue);
    if (settings.locale == null) {
      await prefs.remove(_localeLanguageKey);
    } else {
      await prefs.setString(_localeLanguageKey, settings.locale!.languageCode);
    }
  }
}

lib/main.dart

import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:fortuneslip/fortune_data.dart';
import 'package:fortuneslip/l10n/app_localizations.dart';
import 'package:fortuneslip/settings_page.dart';
import 'package:fortuneslip/ad_manager.dart';
import 'package:fortuneslip/ad_banner_widget.dart';


Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final storage = FortuneStorage();
  final settings = await storage.load();
  runApp(FortuneSlipApp(storage: storage, initialSettings: settings));
}

class FortuneSlipApp extends StatefulWidget {
  const FortuneSlipApp({
    super.key,
    required this.storage,
    required this.initialSettings,
  });

  final FortuneStorage storage;
  final FortuneSettings initialSettings;

  @override
  State<FortuneSlipApp> createState() => _FortuneSlipAppState();
}

class _FortuneSlipAppState extends State<FortuneSlipApp> {
  late FortuneSettings _settings;

  @override
  void initState() {
    super.initState();
    _settings = widget.initialSettings;
  }

  void _updateSettings(FortuneSettings newSettings) {
    setState(() {
      _settings = newSettings;
    });
    unawaited(widget.storage.save(newSettings));
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Fortune Slip',
      theme: _buildLightTheme(),
      darkTheme: _buildDarkTheme(),
      themeMode: _settings.themeMode,
      locale: _settings.locale,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: FortuneHomePage(
        settings: _settings,
        onSettingsChanged: _updateSettings,
      ),
    );
  }
}

ThemeData _buildLightTheme() {
  const primary = Color(0xFFFE0000);
  return ThemeData(
    useMaterial3: true,
    brightness: Brightness.light,
    colorScheme: ColorScheme.fromSeed(seedColor: primary).copyWith(
      primary: primary,
      secondary: const Color(0xFFFF8181),
      surface: const Color(0xFFFFFFFF),
      onPrimary: Colors.white,
      onSecondary: Colors.white,
    ),
    scaffoldBackgroundColor: primary,
    appBarTheme: const AppBarTheme(
      backgroundColor: Color(0xFFFE0000),
      foregroundColor: Colors.white,
      elevation: 0,
    ),
    textTheme: const TextTheme(bodyMedium: TextStyle(color: Colors.white)),
  );
}

ThemeData _buildDarkTheme() {
  return ThemeData(
    useMaterial3: true,
    brightness: Brightness.dark,
    colorScheme: const ColorScheme.dark().copyWith(
      primary: const Color(0xFFFE0000),
      secondary: const Color(0xFFFF8A65),
    ),
    appBarTheme: const AppBarTheme(
      backgroundColor: Color(0xFFFE0000),
      foregroundColor: Colors.white,
      elevation: 0,
    ),
  );
}

class FortuneHomePage extends StatefulWidget {
  const FortuneHomePage({
    super.key,
    required this.settings,
    required this.onSettingsChanged,
  });

  final FortuneSettings settings;
  final ValueChanged<FortuneSettings> onSettingsChanged;

  @override
  State<FortuneHomePage> createState() => _FortuneHomePageState();
}

class _FortuneHomePageState extends State<FortuneHomePage> {
  late AdManager _adManager;
  static const int _frameCount = 120;
  static const List<_TextSpec> _textSpecs = [
    _TextSpec(frame: 84, x: 547, y: 440),
    _TextSpec(frame: 85, x: 549, y: 444),
    _TextSpec(frame: 86, x: 550, y: 450),
    _TextSpec(frame: 87, x: 550, y: 454),
    _TextSpec(frame: 88, x: 552, y: 459),
    _TextSpec(frame: 89, x: 554, y: 464),
    _TextSpec(frame: 90, x: 555, y: 469),
    _TextSpec(frame: 91, x: 556, y: 474),
    _TextSpec(frame: 92, x: 558, y: 479),
    _TextSpec(frame: 93, x: 560, y: 484),
    _TextSpec(frame: 94, x: 562, y: 489),
    _TextSpec(frame: 95, x: 563, y: 495),
    _TextSpec(frame: 96, x: 565, y: 500),
    _TextSpec(frame: 97, x: 567, y: 505),
    _TextSpec(frame: 98, x: 569, y: 511),
    _TextSpec(frame: 99, x: 571, y: 516),
    _TextSpec(frame: 100, x: 573, y: 522),
    _TextSpec(frame: 101, x: 575, y: 530),
    _TextSpec(frame: 102, x: 578, y: 539),
    _TextSpec(frame: 103, x: 580, y: 550),
    _TextSpec(frame: 104, x: 583, y: 563),
    _TextSpec(frame: 105, x: 587, y: 576),
    _TextSpec(frame: 106, x: 590, y: 592),
    _TextSpec(frame: 107, x: 594, y: 608),
    _TextSpec(frame: 108, x: 599, y: 626),
    _TextSpec(frame: 109, x: 604, y: 645),
    _TextSpec(frame: 110, x: 608, y: 665),
    _TextSpec(frame: 111, x: 614, y: 687),
    _TextSpec(frame: 112, x: 619, y: 708),
    _TextSpec(frame: 113, x: 625, y: 730),
    _TextSpec(frame: 114, x: 630, y: 751),
    _TextSpec(frame: 115, x: 636, y: 772),
    _TextSpec(frame: 116, x: 641, y: 792),
    _TextSpec(frame: 117, x: 646, y: 808),
    _TextSpec(frame: 118, x: 651, y: 822),
    _TextSpec(frame: 119, x: 654, y: 832),
  ];
  static const int _countdownFramesPerDigit = 30;
  static const Duration _countdownFrameInterval = Duration(milliseconds: 30);
  static const Duration _ticketFrameDuration = Duration(milliseconds: 40);

  late FortuneSettings _settings;
  final Random _random = Random();
  final FlutterTts _flutterTts = FlutterTts();
  Timer? _animationTimer;
  Timer? _countdownTimer;
  int _currentFrame = 0;
  int? _countdown;
  String? _countdownAsset;
  double _countdownScale = 1.1;
  double _countdownOpacity = 0;
  int _countdownFrames = 0;
  int _countdownValue = 0;
  List<ui.Image> _decodedFrames = [];
  Future<void>? _frameLoadFuture;
  bool _framesReady = false;
  Future<void>? _countdownPrecacheFuture;
  FortuneItem? _activeFortune;
  bool _isAnimating = false;

  bool get _isBusy => _isAnimating || _countdown != null;

  @override
  void initState() {
    super.initState();
    _settings = widget.settings;
    _adManager = AdManager();
    _configureTts();
    unawaited(_flutterTts.setSpeechRate(0.5));
    unawaited(_flutterTts.awaitSpeakCompletion(true));
    _frameLoadFuture = _loadAnimationFrames();
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _countdownPrecacheFuture ??= _precacheCountdownAssets(context);
  }

  @override
  void didUpdateWidget(covariant FortuneHomePage oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.settings != widget.settings) {
      _settings = widget.settings;
      _configureTts();
      setState(() {});
    }
  }

  @override
  void dispose() {
    _adManager.dispose();
    _animationTimer?.cancel();
    _countdownTimer?.cancel();
    unawaited(_flutterTts.stop());
    for (final image in _decodedFrames) {
      image.dispose();
    }
    _decodedFrames = [];
    super.dispose();
  }

  Future<void> _configureTts() async {
    final voiceName = _settings.speechVoice;
    final voiceLocale = _settings.speechLocale;
    try {
      if (voiceLocale.isNotEmpty) {
        await _flutterTts.setLanguage(voiceLocale);
      }
      if (voiceName.isNotEmpty && voiceLocale.isNotEmpty) {
        await _flutterTts.setVoice({'name': voiceName, 'locale': voiceLocale});
        return;
      }
      if (voiceLocale.isNotEmpty) {
        return;
      }
    } catch (_) {
      // Ignore failures and fall back to locale based language.
    }
    final localeCode = _settings.locale?.languageCode ??
        WidgetsBinding.instance.platformDispatcher.locale.languageCode;
    final fallback = localeCode == 'ja' ? 'ja-JP' : 'en-US';
    try {
      await _flutterTts.setLanguage(fallback);
    } catch (_) {
      // Best effort; ignore errors from the platform TTS engine.
    }
  }

  void _handleTap() {
    if (_isBusy) {
      return;
    }
    final selected = _selectFortune();
    if (selected == null) {
      final messenger = ScaffoldMessenger.of(context);
      messenger.hideCurrentSnackBar();
      messenger.showSnackBar(
        SnackBar(content: Text(AppLocalizations.of(context)!.empty)),
      );
      return;
    }
    setState(() {
      _activeFortune = selected;
      _currentFrame = 0;
      _isAnimating = false;
    });

    final int countdownTarget = _settings.countdownTime.clamp(0, 9);
    if (countdownTarget <= 0) {
      setState(() {
        _countdown = null;
        _countdownAsset = null;
        _countdownOpacity = 0;
      });
      unawaited(_startAnimation());
      return;
    }

    setState(() {
      _countdownValue = countdownTarget;
      _countdown = _countdownValue;
      _countdownFrames = _countdownFramesPerDigit;
      _countdownAsset = _countdownAssetFor(_countdownValue);
      _countdownScale = 1.1;
      _countdownOpacity = 0;
    });
    _countdownTimer?.cancel();
    _countdownTimer = Timer.periodic(_countdownFrameInterval, (timer) {
      if (!mounted) {
        timer.cancel();
        return;
      }
      setState(() {
        _countdownFrames -= 1;
        if (_countdownFrames <= 0) {
          _countdownValue -= 1;
          if (_countdownValue <= 0) {
            _countdown = null;
            _countdownAsset = null;
            _countdownOpacity = 0;
            timer.cancel();
            unawaited(_startAnimation());
            return;
          }
          _countdown = _countdownValue;
          _countdownFrames = _countdownFramesPerDigit;
          _countdownAsset = _countdownAssetFor(_countdownValue);
        }

        final frame = _countdownFrames.toDouble();
        _countdownScale = 1 + 0.1 * (frame / _countdownFramesPerDigit);
        if (frame >= 20) {
          _countdownOpacity = (_countdownFramesPerDigit - frame) / 10;
        } else if (frame <= 5) {
          _countdownOpacity = frame / 5;
        } else {
          _countdownOpacity = 1;
        }
        if (_countdownOpacity < 0) {
          _countdownOpacity = 0;
        } else if (_countdownOpacity > 1) {
          _countdownOpacity = 1;
        }
      });
    });
  }

  Future<void> _precacheCountdownAssets(BuildContext context) async {
    final ctx = context;
    final futures = <Future<void>>[];
    for (var i = 1; i <= 9; i++) {
      // ignore: use_build_context_synchronously
      futures.add(precacheImage(AssetImage(_countdownAssetFor(i)), ctx));
    }
    // ignore: use_build_context_synchronously
    futures.add(
      precacheImage(const AssetImage('assets/image/number_null.webp'), ctx),
    );
    try {
      await Future.wait(futures);
    } catch (_) {
      // Ignore errors; countdown images will fall back to on-demand decoding.
    }
  }

  Future<void> _loadAnimationFrames() async {
    final frames = <ui.Image>[];
    try {
      for (var i = 0; i < _frameCount; i++) {
        final data = await rootBundle.load(_frameAsset(i));
        final codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
        try {
          final frame = await codec.getNextFrame();
          frames.add(frame.image);
        } finally {
          codec.dispose();
        }
      }
      if (!mounted) {
        for (final image in frames) {
          image.dispose();
        }
        return;
      }
      setState(() {
        _decodedFrames = frames;
        _framesReady = true;
      });
    } catch (_) {
      for (final image in frames) {
        image.dispose();
      }
      if (mounted) {
        setState(() {
          _decodedFrames = [];
          _framesReady = true;
        });
      } else {
        _framesReady = true;
      }
    }
  }

  Future<void> _ensureFramesReady() async {
    if (_framesReady) {
      return;
    }
    final future = _frameLoadFuture;
    if (future != null) {
      try {
        await future;
      } catch (_) {
        // Ignore errors and fall back to asset-based rendering.
      }
    }
  }

  Future<void> _startAnimation() async {
    _animationTimer?.cancel();
    await _ensureFramesReady();
    if (!mounted) {
      return;
    }
    setState(() {
      _isAnimating = true;
      _currentFrame = 0;
    });
    _animationTimer = Timer.periodic(_ticketFrameDuration, (timer) {
      if (!mounted) {
        timer.cancel();
        return;
      }
      setState(() {
        if (_currentFrame >= _frameCount - 1) {
          timer.cancel();
          _isAnimating = false;
          _currentFrame = _frameCount - 1;
          _speakResult();
        } else {
          _currentFrame += 1;
        }
      });
    });
  }

  FortuneItem? _selectFortune() {
    final candidates = _settings.items
        .where((item) => item.ratio > 0 && item.display.isNotEmpty)
        .toList();
    final total = candidates.fold<int>(0, (value, item) => value + item.ratio);
    if (total == 0) {
      return null;
    }
    var remain = _random.nextInt(total);
    for (final item in candidates) {
      remain -= item.ratio;
      if (remain < 0) {
        return item;
      }
    }
    return candidates.isNotEmpty ? candidates.last : null;
  }

  Future<void> _speakResult() async {
    if (!_settings.speakResult || _activeFortune == null) {
      return;
    }
    await _flutterTts.stop();
    final text = _activeFortune!.reading;
    if (text.isEmpty) {
      return;
    }
    await _flutterTts.speak(text);
  }

  Future<void> _openSettings() async {
    if (_isBusy) {
      return;
    }
    final result = await Navigator.of(context).push<FortuneSettings>(
      MaterialPageRoute(
        builder: (context) => SettingsPage(settings: _settings),
      ),
    );
    if (result != null) {
      widget.onSettingsChanged(result);
    }
  }

  @override
  Widget build(BuildContext context) {
    final localization = AppLocalizations.of(context)!;
    return Scaffold(
      appBar: AppBar(
        title: Text(localization.appTitle),
        actions: [
          TextButton(
            onPressed: _isBusy ? null : _openSettings,
            child: Text(
              localization.setting,
              style: TextStyle(
                color: _isBusy
                    ? Colors.white.withValues(alpha: 0.4)
                    : Colors.white,
              ),
            ),
          ),
        ],
      ),
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: _handleTap,
        child: Container(
          color: const Color(0xFFFE0000),
          child: SafeArea(
            child: Column(
              children: [
                Padding(
                  padding: const EdgeInsets.only(top: 16, bottom: 12),
                  child: SizedBox(
                    width: double.infinity,
                    child: Text(
                      localization.tapToDraw,
                      textAlign: TextAlign.center,
                      style: const TextStyle(
                        color: Color.fromRGBO(255, 255, 255, 0.5),
                        fontSize: 14,
                      ),
                    ),
                  ),
                ),
                Expanded(
                  child: Stack(
                    children: [
                      Positioned.fill(child: _buildDrawingArea()),
                      if (_countdownAsset != null) _buildCountdownOverlay(),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
      bottomNavigationBar: Container(
        color: Color(0xFFFE0000),
        child: AdBannerWidget(adManager: _adManager),
      )
    );
  }

  Widget _buildDrawingArea() {
    return LayoutBuilder(
      builder: (context, constraints) {
        final width = constraints.maxWidth;
        final height = constraints.maxHeight;
        final boxSize = min(width, height);
        final marginLeft = (width - boxSize) / 2;
        final marginTop = (height - boxSize) / 2;
        final textLayout = _layoutForFrame(
          _currentFrame,
          boxSize,
          marginLeft,
          marginTop,
        );
        final imageAsset = _frameAsset(_currentFrame);
        final ui.Image? frameImage = _decodedFrames.isEmpty
            ? null
            : _decodedFrames[_currentFrame.clamp(0, _decodedFrames.length - 1)];
        return Stack(
          children: [
            Positioned(
              left: marginLeft,
              top: marginTop,
              width: boxSize,
              height: boxSize,
              child: DecoratedBox(
                decoration: BoxDecoration(
                  color: Colors.black.withValues(alpha: 0.1),
                  borderRadius: BorderRadius.circular(8),
                ),
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(8),
                  child: frameImage != null
                      ? CustomPaint(
                          painter: _FramePainter(frameImage),
                          child: const SizedBox.expand(),
                        )
                      : Image.asset(
                          imageAsset,
                          fit: BoxFit.contain,
                          key: ValueKey<int>(_currentFrame),
                          gaplessPlayback: true,
                        ),
                ),
              ),
            ),
            if (textLayout != null && _activeFortune != null)
              Positioned(
                left: textLayout.offset.dx,
                top: textLayout.offset.dy,
                child: Opacity(
                  opacity: _currentFrame >= 84 ? 1 : 0,
                  child: Text(
                    _activeFortune!.display,
                    style: TextStyle(
                      fontSize: textLayout.fontSize,
                      color: Colors.white,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
              ),
          ],
        );
      },
    );
  }

  Widget _buildCountdownOverlay() {
    final asset = _countdownAsset;
    if (asset == null) {
      return const SizedBox.shrink();
    }
    return Positioned.fill(
      child: Container(
        color: Colors.transparent,
        child: Center(
          child: Opacity(
            opacity: _countdownOpacity.clamp(0.0, 1.0),
            child: Transform.scale(
              scale: _countdownScale,
              child: Image.asset(asset),
            ),
          ),
        ),
      ),
    );
  }

  _TextLayout? _layoutForFrame(
    int frame,
    double boxSize,
    double marginLeft,
    double marginTop,
  ) {
    if (frame < 84) {
      return null;
    }
    final spec = _textSpecs.firstWhere(
      (element) => element.frame == frame,
      orElse: () => const _TextSpec(frame: -1, x: 0, y: 0),
    );
    if (spec.frame == -1) {
      return null;
    }
    final boxPixel = boxSize / 900.0;
    final dx = marginLeft + boxPixel * spec.x - boxPixel * ((frame - 84) + 36);
    final dy =
        marginTop + boxPixel * spec.y - boxPixel * ((frame - 84) * 1.3 + 24);
    final fontSize = ((frame - 84) / (119 - 84)) * 14 + 4;
    return _TextLayout(offset: Offset(dx, dy), fontSize: fontSize);
  }

  String _frameAsset(int frame) {
    final clamped = frame.clamp(0, 119) + 1;
    final padded = clamped.toString().padLeft(3, '0');
    return 'assets/image/fortune/omikuji$padded.jpg';
  }

  String _countdownAssetFor(int value) {
    var clamped = value;
    if (clamped <= 0) {
      return 'assets/image/number_null.webp';
    }
    if (clamped > 9) {
      clamped = 9;
    }
    return 'assets/image/number${clamped.toString()}.webp';
  }
}

class _FramePainter extends CustomPainter {
  const _FramePainter(this.image);

  final ui.Image image;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = ui.Paint()..filterQuality = ui.FilterQuality.high;
    final imageWidth = image.width.toDouble();
    final imageHeight = image.height.toDouble();
    if (imageWidth == 0 ||
        imageHeight == 0 ||
        size.width == 0 ||
        size.height == 0) {
      return;
    }
    final imageAspect = imageWidth / imageHeight;
    final canvasAspect = size.width / size.height;
    Rect dst;
    if (imageAspect > canvasAspect) {
      final drawHeight = size.width / imageAspect;
      final dy = (size.height - drawHeight) / 2.0;
      dst = Rect.fromLTWH(0, dy, size.width, drawHeight);
    } else {
      final drawWidth = size.height * imageAspect;
      final dx = (size.width - drawWidth) / 2.0;
      dst = Rect.fromLTWH(dx, 0, drawWidth, size.height);
    }
    final src = Rect.fromLTWH(0, 0, imageWidth, imageHeight);
    canvas.drawImageRect(image, src, dst, paint);
  }

  @override
  bool shouldRepaint(covariant _FramePainter oldDelegate) {
    return oldDelegate.image != image;
  }
}

class _TextSpec {
  const _TextSpec({required this.frame, required this.x, required this.y});

  final int frame;
  final double x;
  final double y;
}

class _TextLayout {
  const _TextLayout({required this.offset, required this.fontSize});

  final Offset offset;
  final double fontSize;
}

lib/settings_page.dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:fortuneslip/fortune_data.dart';
import 'package:fortuneslip/l10n/app_localizations.dart';
import 'package:fortuneslip/ad_manager.dart';
import 'package:fortuneslip/ad_banner_widget.dart';

class SettingsPage extends StatefulWidget {
  const SettingsPage({super.key, required this.settings});
  final FortuneSettings settings;
  @override
  State<SettingsPage> createState() => _SettingsPageState();
}

class _SettingsPageState extends State<SettingsPage> {
  late AdManager _adManager;
  late List<TextEditingController> _nameControllers;
  late List<TextEditingController> _ratioControllers;
  late bool _speakResult;
  late int _themeSelection;
  late String _languageCode;
  late int _countdownTime;
  List<_VoiceOption> _voiceOptions = const [];
  bool _voicesLoading = false;
  String _selectedVoiceId = '';

  @override
  void initState() {
    super.initState();
    _adManager = AdManager();
    _nameControllers = widget.settings.items
        .map((item) => TextEditingController(text: item.rawName))
        .toList();
    _ratioControllers = widget.settings.items
        .map(
          (item) => TextEditingController(
            text: item.ratio > 0 ? item.ratio.toString() : '',
          ),
        )
        .toList();
    _speakResult = widget.settings.speakResult;
    _themeSelection = _themeModeToValue(widget.settings.themeMode);
    _languageCode = widget.settings.locale?.languageCode ?? '';
    _countdownTime = widget.settings.countdownTime;
    _selectedVoiceId =
        widget.settings.speechVoice.isNotEmpty &&
            widget.settings.speechLocale.isNotEmpty
        ? _VoiceOption(
            widget.settings.speechLocale,
            widget.settings.speechVoice,
          ).id
        : '';
    _voicesLoading = true;
    _loadVoices();
  }

  Future<void> _loadVoices() async {
    try {
      final tts = FlutterTts();
      final voices = await tts.getVoices;
      final optionsById = <String, _VoiceOption>{};
      if (voices is List) {
        for (final voice in voices) {
          if (voice is Map) {
            final name = voice['name'];
            final locale = voice['locale'];
            if (name is String && locale is String) {
              final option = _VoiceOption(locale, name);
              optionsById.putIfAbsent(option.id, () => option);
            }
          }
        }
      }
      final options = optionsById.values.toList()
        ..sort((a, b) => a.label.compareTo(b.label));
      if (!mounted) {
        return;
      }
      setState(() {
        _voiceOptions = options;
        if (_selectedVoiceId.isNotEmpty &&
            options.every((option) => option.id != _selectedVoiceId)) {
          _selectedVoiceId = '';
        }
        _voicesLoading = false;
      });
    } catch (_) {
      if (!mounted) {
        return;
      }
      setState(() {
        _voiceOptions = const [];
        _selectedVoiceId = '';
        _voicesLoading = false;
      });
    }
  }

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

  @override
  Widget build(BuildContext context) {
    final localization = AppLocalizations.of(context)!;
    final theme = Theme.of(context);
    final backColor = theme.brightness == Brightness.light
        ? Colors.white
        : Colors.black;
    final foreColor = theme.brightness == Brightness.light
        ? Colors.grey[800]
        : Colors.grey[300];
    const accentColor = Color(0xFF8C1A1A);
    final themed = theme.copyWith(
      colorScheme: theme.colorScheme.copyWith(
        primary: accentColor,
        secondary: accentColor,
        tertiary: accentColor,
      ),
      sliderTheme: theme.sliderTheme.copyWith(
        activeTrackColor: accentColor,
        inactiveTrackColor: accentColor.withValues(alpha: 0.3),
        thumbColor: accentColor,
        overlayColor: accentColor.withValues(alpha: 0.12),
      ),
    );
    return Theme(
      data: themed,
      child: Scaffold(
        backgroundColor: backColor,
        appBar: AppBar(
          automaticallyImplyLeading: false,
          backgroundColor: Colors.transparent,
          leading: IconButton(
            icon: const Icon(Icons.close),
            tooltip: localization.cancel,
            onPressed: () => Navigator.of(context).pop(),
            style: IconButton.styleFrom(foregroundColor: foreColor),
          ),
          actions: <Widget>[
            Padding(
              padding: const EdgeInsets.only(right: 10),
              child: IconButton(
                icon: const Icon(Icons.check),
                tooltip: localization.apply,
                onPressed: _apply,
                style: IconButton.styleFrom(foregroundColor: foreColor),
              ),
            ),
          ],
        ),
        body: SafeArea(
          child: GestureDetector(
            behavior: HitTestBehavior.translucent,
            onTap: () => FocusScope.of(context).unfocus(),
            child: ListView(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
              children: [
                _buildFortuneTable(localization),
                const SizedBox(height: 8),
                Text(
                  localization.fortunesHint,
                  style: Theme.of(context).textTheme.bodySmall,
                ),
                const Divider(height: 40),
                _buildCountdownSlider(localization),
                const Divider(height: 40),
                SwitchListTile(
                  contentPadding: EdgeInsets.zero,
                  title: Text(localization.speakResult),
                  value: _speakResult,
                  activeThumbColor: accentColor,
                  activeTrackColor: accentColor.withValues(
                    alpha: theme.brightness == Brightness.dark ? 0.6 : 0.5,
                  ),
                  onChanged: (value) {
                    setState(() {
                      _speakResult = value;
                    });
                  },
                ),
                const SizedBox(height: 8),
                _buildVoiceSelector(localization, accentColor),
                const Divider(height: 40),
                ListTile(
                  contentPadding: EdgeInsets.zero,
                  title: Text(localization.theme),
                  trailing: DropdownButtonHideUnderline(
                    child: DropdownButton<int>(
                      value: _themeSelection,
                      iconEnabledColor: accentColor,
                      iconDisabledColor: accentColor.withValues(alpha: 0.4),
                      items: [
                        DropdownMenuItem(
                          value: 0,
                          child: Text(localization.themeSystem),
                        ),
                        DropdownMenuItem(
                          value: 1,
                          child: Text(localization.lightTheme),
                        ),
                        DropdownMenuItem(
                          value: 2,
                          child: Text(localization.darkTheme),
                        ),
                      ],
                      onChanged: (value) {
                        if (value == null) {
                          return;
                        }
                        setState(() {
                          _themeSelection = value;
                        });
                      },
                    ),
                  ),
                ),
                ListTile(
                  contentPadding: EdgeInsets.zero,
                  title: Text(localization.language),
                  trailing: DropdownButtonHideUnderline(
                    child: DropdownButton<String>(
                      value: _languageCode,
                      iconEnabledColor: accentColor,
                      iconDisabledColor: accentColor.withValues(alpha: 0.4),
                      items: [
                        DropdownMenuItem(
                          value: '',
                          child: Text(localization.languageSystem),
                        ),
                        DropdownMenuItem(
                          value: 'en',
                          child: Text(localization.languageEn),
                        ),
                        DropdownMenuItem(
                          value: 'ja',
                          child: Text(localization.languageJa),
                        ),
                      ],
                      onChanged: (value) {
                        setState(() {
                          _languageCode = value ?? '';
                        });
                      },
                    ),
                  ),
                ),
                const Divider(height: 40),
                const SizedBox(height: 100),
              ],
            ),
          ),
        ),
        bottomNavigationBar: AdBannerWidget(adManager: _adManager),
      ),
    );
  }

  Widget _buildVoiceSelector(AppLocalizations localization, Color accentColor) {
    final dropdownItems = <DropdownMenuItem<String>>[
      DropdownMenuItem(value: '', child: Text(localization.voiceDefault)),
      ..._voiceOptions.map(
        (option) => DropdownMenuItem<String>(
          value: option.id,
          child: Text(option.label),
        ),
      ),
    ];
    final hasSelectedVoice = _voiceOptions.any(
      (option) => option.id == _selectedVoiceId,
    );
    final value = hasSelectedVoice ? _selectedVoiceId : '';
    return ListTile(
      contentPadding: EdgeInsets.zero,
      title: Text(localization.voice),
      subtitle: _voicesLoading
          ? Text(localization.voiceLoading)
          : (_voiceOptions.isEmpty ? Text(localization.voiceDefault) : null),
      trailing: DropdownButtonHideUnderline(
        child: DropdownButton<String>(
          value: value,
          iconEnabledColor: accentColor,
          iconDisabledColor: accentColor.withValues(alpha: 0.4),
          items: dropdownItems,
          onChanged: _voicesLoading
              ? null
              : (selected) {
                  if (selected == null) {
                    return;
                  }
                  setState(() {
                    _selectedVoiceId = selected;
                  });
                },
        ),
      ),
    );
  }

  Widget _buildFortuneTable(AppLocalizations localization) {
    final headerStyle = Theme.of(context).textTheme.titleMedium;
    final foreColor = Theme.of(context).brightness == Brightness.light
        ? Colors.grey[800]
        : Colors.grey[300];
    return Table(
      columnWidths: const {
        0: IntrinsicColumnWidth(),
        1: FlexColumnWidth(),
        2: IntrinsicColumnWidth(),
        3: IntrinsicColumnWidth(),
      },
      defaultVerticalAlignment: TableCellVerticalAlignment.middle,
      children: [
        TableRow(
          children: [
            Padding(
              padding: const EdgeInsets.symmetric(vertical: 8),
              child: Text(localization.fortuneLabel, style: headerStyle),
            ),
            const SizedBox(),
            Padding(
              padding: const EdgeInsets.symmetric(vertical: 8),
              child: Text(localization.ratioLabel, style: headerStyle),
            ),
            const SizedBox(),
          ],
        ),
        for (var i = 0; i < _nameControllers.length; i++)
          TableRow(
            children: [
              Text('${i + 1}', style: TextStyle(color: foreColor)),
              Padding(
                padding: const EdgeInsets.only(left: 0, right: 4, bottom: 4),
                child: TextField(
                  controller: _nameControllers[i],
                  decoration: const InputDecoration(
                    isDense: true,
                    border: OutlineInputBorder(),
                  ),
                ),
              ),
              Padding(
                padding: const EdgeInsets.only(right: 0, bottom: 4),
                child: SizedBox(
                  width: 80,
                  child: TextField(
                    controller: _ratioControllers[i],
                    keyboardType: TextInputType.number,
                    decoration: const InputDecoration(
                      isDense: true,
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
              ),
              const SizedBox.shrink(),
            ],
          ),
      ],
    );
  }

  Widget _buildCountdownSlider(AppLocalizations localization) {
    final theme = Theme.of(context);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(localization.countdownTime, style: theme.textTheme.titleMedium),
        Row(
          children: [
            Expanded(
              flex: 1,
              child: Slider(
                value: _countdownTime.toDouble(),
                min: 0,
                max: 9,
                divisions: 9,
                label: _countdownTime.toString(),
                onChanged: (value) {
                  setState(() {
                    _countdownTime = value.round();
                  });
                },
              ),
            ),
            SizedBox(
              width: 20,
              child: Text(
                _countdownTime.toString(),
                style: theme.textTheme.bodySmall,
                textAlign: TextAlign.center,
              ),
            ),
          ],
        ),
      ],
    );
  }

  void _apply() {
    final locale = _languageCode.isEmpty ? null : Locale(_languageCode);
    final parts = _selectedVoiceId.split('|');
    String speechLocale = '';
    String speechVoice = '';
    if (parts.length == 2) {
      speechLocale = parts[0];
      speechVoice = parts[1];
    }
    final items = List<FortuneItem>.generate(_nameControllers.length, (index) {
      final name = _nameControllers[index].text.trim();
      final ratioText = _ratioControllers[index].text.trim();
      final ratio = int.tryParse(ratioText) ?? 0;
      return FortuneItem(rawName: name, ratio: max(0, ratio));
    });
    final allEmpty = items.every((item) => item.rawName.isEmpty);
    final normalizedItems = allEmpty
        ? FortuneDefaults.forLocale(locale)
        : items;
    final updated = widget.settings.copyWith(
      items: normalizedItems,
      speakResult: _speakResult,
      countdownTime: _countdownTime,
      themeMode: _valueToThemeMode(_themeSelection),
      locale: locale,
      speechVoice: speechVoice,
      speechLocale: speechLocale,
    );
    Navigator.of(context).pop(updated);
  }

  int _themeModeToValue(ThemeMode mode) {
    switch (mode) {
      case ThemeMode.system:
        return 0;
      case ThemeMode.light:
        return 1;
      case ThemeMode.dark:
        return 2;
    }
  }

  ThemeMode _valueToThemeMode(int value) {
    switch (value) {
      case 0:
        return ThemeMode.system;
      case 1:
        return ThemeMode.light;
      case 2:
        return ThemeMode.dark;
      default:
        return ThemeMode.system;
    }
  }
}

class _VoiceOption {
  const _VoiceOption(this.locale, this.name);

  final String locale;
  final String name;

  String get id => '$locale|$name';
  String get label => '$locale $name';
}