ソースコード source code

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

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

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