ソースコード source code

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

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

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

pubspec.yaml

name: bingomachine
description: "bingomachine"
# 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.1+54

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

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.8
  flutter_localizations:
    sdk: flutter
  intl: ^0.20.2
  shared_preferences: ^2.2.3
  package_info_plus: ^9.0.0
  google_mobile_ads: ^6.0.0
  just_audio: ^0.10.4
  collection: ^1.18.0
  video_player: ^2.8.7
  flutter_tts: ^4.0.2
  audio_session: ^0.2.2
  flutter_svg: ^2.0.10+1

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

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

flutter_icons:
  android: "launcher_icon"
  ios: true
  image_path: "assets/icon/icon.png"
  adaptive_icon_background: "assets/icon/icon_back.png"
  adaptive_icon_foreground: "assets/icon/icon_fore.png"

flutter_native_splash:
  color: '#02D372'
  image: 'assets/image/splash.png'
  color_dark: '#02D372'
  image_dark: 'assets/image/splash.png'
  fullscreen: true
  android_12:
    icon_background_color: '#02D372'
    image: 'assets/image/splash.png'
    icon_background_color_dark: '#02D372'
    image_dark: 'assets/image/splash.png'

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

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

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

  assets:
    - assets/image/
    - assets/audio/
    - assets/video/

  # 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:flutter/foundation.dart' show kIsWeb;
import 'package:google_mobile_ads/google_mobile_ads.dart';

import 'package:bingomachine/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) {
    if (kIsWeb) {
      return const SizedBox.shrink();
    }
    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: [
                const 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:flutter/foundation.dart' show kIsWeb;
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 {
    if (kIsWeb) {
      return;
    }
    _onLoadedCb = onAdLoaded;
    _lastWidthPx = widthPx;
    _retryAttempt = 0;
    _retryTimer?.cancel();
    _startLoad(widthPx);
  }

  Future<void> _startLoad(int widthPx) async {
    if (kIsWeb) {
      return;
    }
    _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() {
    if (kIsWeb) {
      return;
    }
    _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/ball_result.dart

import 'package:flutter/material.dart';

class BallResult extends StatelessWidget {
  const BallResult({super.key, required this.number, required this.size});

  final String number;
  final double size;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: size,
      height: size,
      child: Stack(
        children: [
          Container(
            decoration: const BoxDecoration(
              shape: BoxShape.circle,
              gradient: RadialGradient(
                colors: [
                  Color(0xFFE7E7E7),
                  Color(0xFFDCDCDC),
                  Color(0xFFD5D5D5),
                  Color(0xFFE1E1E1),
                ],
                stops: [0.0, 0.52, 0.75, 1.0],
              ),
              boxShadow: [
                BoxShadow(
                  color: Colors.black26,
                  blurRadius: 12,
                  offset: Offset(0, 6),
                ),
              ],
            ),
          ),
          Align(
            alignment: Alignment.topCenter,
            child: FractionallySizedBox(
              heightFactor: 0.45,
              child: Container(
                decoration: const BoxDecoration(
                  shape: BoxShape.circle,
                  gradient: LinearGradient(
                    begin: Alignment.bottomCenter,
                    end: Alignment.topCenter,
                    colors: [Color(0xFFEEEEEE), Color(0xFFF3F3F3), Color(0xFFFFFFFF)],
                  ),
                ),
              ),
            ),
          ),
          Align(
            alignment: Alignment.bottomCenter,
            child: FractionallySizedBox(
              heightFactor: 0.4,
              child: Container(
                decoration: const BoxDecoration(
                  shape: BoxShape.circle,
                  gradient: LinearGradient(
                    begin: Alignment.topCenter,
                    end: Alignment.bottomCenter,
                    colors: [Color(0xFFE9E9E9), Color(0xFFE1E1E1)],
                  ),
                ),
              ),
            ),
          ),
          Center(
            child: Text(
              number,
              style: TextStyle(
                fontSize: size * 0.4,
                fontWeight: FontWeight.bold,
                color: Colors.black87,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

lib/card_page.dart

import 'dart:async';
import 'dart:math';

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

import 'package:bingomachine/ad_banner_widget.dart';
import 'package:bingomachine/ad_manager.dart';
import 'package:bingomachine/l10n/app_localizations.dart';
import 'package:bingomachine/loading_screen.dart';
import 'package:bingomachine/preferences.dart';
import 'package:bingomachine/text_to_speech.dart';
import 'package:bingomachine/theme_color.dart';

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

class _CardPageState extends State<CardPage> {
  static const int _gridSize = 5;
  static final Random _random = Random();

  late final AdManager _adManager;
  final TextEditingController _freeText1Controller = TextEditingController();
  final TextEditingController _freeText2Controller = TextEditingController();

  late List<List<_CardCell>> _grid;
  final Set<int> _highlighted = <int>{};
  late ThemeColor _themeColor;
  bool _ready = false;
  bool _isFirst = true;

  @override
  void initState() {
    super.initState();
    _initialize();
  }

  Future<void> _initialize() async {
    _adManager = AdManager();
    _grid = List.generate(
      _gridSize,
          (_) => List.generate(_gridSize, (_) => _CardCell(number: 0)),
    );
    await Preferences.ensureReady();
    _freeText1Controller.text = Preferences.freeText1;
    _freeText2Controller.text = Preferences.freeText2;
    await _applyTts();
    final stored = Preferences.cardState;
    final bool restored = _loadStoredState(stored);
    if (!restored) {
      _generateNewCard();
      unawaited(_saveCardState());
    }
    _updateHighlights();
    if (mounted) {
      setState(() {
        _ready = true;
      });
    }
  }

  @override
  void dispose() {
    unawaited(TextToSpeech.stop());
    _adManager.dispose();
    _freeText1Controller.dispose();
    _freeText2Controller.dispose();
    super.dispose();
  }

  Future<void> _applyTts() async {
    await TextToSpeech.getInstance();
    await TextToSpeech.setVolume(Preferences.ttsVolume);
    TextToSpeech.setTtsVoiceId(Preferences.ttsVoiceId);
    await TextToSpeech.setSpeechVoiceFromId();
  }

  @override
  Widget build(BuildContext context) {
    if (!_ready) {
      return const LoadingScreen();
    }
    if (_isFirst) {
      _isFirst = false;
      _themeColor = ThemeColor(context: context);
    }
    final l = AppLocalizations.of(context)!;
    return Scaffold(
      backgroundColor: _themeColor.cardBackColor,
      appBar: AppBar(
        title: Text(l.participantMode),
        centerTitle: true,
        backgroundColor: Colors.transparent,
        foregroundColor: _themeColor.cardTitleColor,
        elevation: 0,
      ),
      body: GestureDetector(
        onTap: () => FocusScope.of(context).unfocus(),
        child: Column(
          children: [
            Expanded(
              child: SingleChildScrollView(
                padding: const EdgeInsets.only(left: 12, right: 12, top: 10, bottom: 100),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    _buildHeaderRow(),
                    const SizedBox(height: 12),
                    _buildGrid(),
                    const SizedBox(height: 24),
                    _buildFreeTextRow(
                      controller: _freeText1Controller,
                      onChanged: (value) => _onFreeTextChanged(1, value),
                      onSpeak: () => _speakText(_freeText1Controller.text),
                    ),
                    const SizedBox(height: 12),
                    _buildFreeTextRow(
                      controller: _freeText2Controller,
                      onChanged: (value) => _onFreeTextChanged(2, value),
                      onSpeak: () => _speakText(_freeText2Controller.text),
                    ),
                  ],
                ),
              ),
            ),
          ],
        )
      ),
      bottomNavigationBar: AdBannerWidget(adManager: _adManager),
    );
  }

  Widget _buildHeaderRow() {
    const letters = ['B', 'I', 'N', 'G', 'O'];
    return Row(
      children: List.generate(
        _gridSize,
        (index) => Expanded(
          child: Container(
            margin: const EdgeInsets.symmetric(horizontal: 4),
            padding: const EdgeInsets.symmetric(vertical: 4),
            decoration: BoxDecoration(
              color: _themeColor.cardSubjectBackColor,
              borderRadius: BorderRadius.circular(8),
            ),
            child: Text(
              letters[index],
              textAlign: TextAlign.center,
              style: TextStyle(
                color: _themeColor.cardSubjectForeColor,
                fontSize: Preferences.textSizeCard.toDouble(),
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildGrid() {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 4),
      child: GridView.builder(
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        itemCount: _gridSize * _gridSize,
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: _gridSize,
          crossAxisSpacing: 8,
          mainAxisSpacing: 8,
          childAspectRatio: 1,
        ),
        itemBuilder: (context, index) {
          final row = index ~/ _gridSize;
          final col = index % _gridSize;
          return _buildCell(row, col);
        },
      )
    );
  }

  Widget _buildCell(int row, int col) {
    final cell = _grid[row][col];
    final bool isCenter = row == 2 && col == 2;
    final bool isOpen = cell.open || isCenter;
    final int index = _indexOf(row, col);
    final bool highlighted = _highlighted.contains(index);
    final Color background = isOpen
        ? _themeColor.cardTableOpenBackColor
        : _themeColor.cardTableCloseBackColor;
    final Color baseTextColor = isOpen ? _themeColor.cardTableOpenForeColor : _themeColor.cardTableCloseForeColor;
    final Color textColor = highlighted ? _themeColor.cardTableBingoForeColor : baseTextColor;
    final String label = isCenter ? 'F' : cell.number.toString();
    final child = AnimatedContainer(
      duration: const Duration(milliseconds: 200),
      decoration: BoxDecoration(
        color: background,
        borderRadius: BorderRadius.circular(10),
        boxShadow: const [
          BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2)),
        ],
      ),
      alignment: Alignment.center,
      child: Text(
        label,
        style: TextStyle(
          fontSize: Preferences.textSizeCard.toDouble(),
          fontWeight: FontWeight.bold,
          color: textColor,
        ),
      ),
    );

    if (isCenter) {
      return child;
    }

    return InkWell(
      borderRadius: BorderRadius.circular(10),
      onTap: () => _toggleCell(row, col),
      child: child,
    );
  }

  Widget _buildFreeTextRow({
    required TextEditingController controller,
    required ValueChanged<String> onChanged,
    required VoidCallback onSpeak,
  }) {
    return Row(
      children: [
        Expanded(
          child: TextField(
            controller: controller,
            textInputAction: TextInputAction.done,
            onChanged: (value) {
              onChanged(value);
              setState(() {});
            },
            decoration: const InputDecoration(
              border: OutlineInputBorder(),
              isDense: true,
            ),
          ),
        ),
        const SizedBox(width: 12),
        IconButton(
          icon: const Icon(Icons.volume_up),
          tooltip: 'Speak',
          onPressed: controller.text.trim().isEmpty ? null : onSpeak,
        ),
      ],
    );
  }

  void _toggleCell(int row, int col) {
    HapticFeedback.selectionClick();
    setState(() {
      final cell = _grid[row][col];
      cell.open = !cell.open;
      _updateHighlights();
    });
    unawaited(_saveCardState());
  }

  void _updateHighlights() {
    _highlighted.clear();
    for (int row = 0; row < _gridSize; row++) {
      bool allOpen = true;
      for (int col = 0; col < _gridSize; col++) {
        if (!_grid[row][col].open && !(row == 2 && col == 2)) {
          allOpen = false;
          break;
        }
      }
      if (allOpen) {
        for (int col = 0; col < _gridSize; col++) {
          _highlighted.add(_indexOf(row, col));
        }
      }
    }

    for (int col = 0; col < _gridSize; col++) {
      bool allOpen = true;
      for (int row = 0; row < _gridSize; row++) {
        if (!_grid[row][col].open && !(row == 2 && col == 2)) {
          allOpen = false;
          break;
        }
      }
      if (allOpen) {
        for (int row = 0; row < _gridSize; row++) {
          _highlighted.add(_indexOf(row, col));
        }
      }
    }

    bool diag1 = true;
    for (int i = 0; i < _gridSize; i++) {
      if (!_grid[i][i].open && !(i == 2 && i == 2)) {
        diag1 = false;
        break;
      }
    }
    if (diag1) {
      for (int i = 0; i < _gridSize; i++) {
        _highlighted.add(_indexOf(i, i));
      }
    }

    bool diag2 = true;
    for (int i = 0; i < _gridSize; i++) {
      final int row = i;
      final int col = _gridSize - 1 - i;
      if (!_grid[row][col].open && !(row == 2 && col == 2)) {
        diag2 = false;
        break;
      }
    }
    if (diag2) {
      for (int i = 0; i < _gridSize; i++) {
        _highlighted.add(_indexOf(i, _gridSize - 1 - i));
      }
    }
  }

  void _generateNewCard() {
    for (int col = 0; col < _gridSize; col++) {
      final int base = col * 15;
      final numbers = List<int>.generate(15, (index) => base + index + 1);
      numbers.shuffle(_random);
      for (int row = 0; row < _gridSize; row++) {
        _grid[row][col]
          ..number = numbers[row]
          ..open = false;
      }
    }
    _grid[2][2].open = true;
  }

  bool _loadStoredState(String stored) {
    if (stored.isEmpty) {
      return false;
    }
    final entries = stored
        .split(',')
        .where((element) => element.isNotEmpty)
        .toList();
    if (entries.length < _gridSize * _gridSize) {
      return false;
    }
    int index = 0;
    for (int col = 0; col < _gridSize; col++) {
      for (int row = 0; row < _gridSize; row++) {
        final parts = entries[index].split(':');
        final number = int.tryParse(parts[0]) ?? _defaultNumberFor(row, col);
        final isOpen = parts.length > 1 && parts[1].toLowerCase() == 'true';
        _grid[row][col]
          ..number = number
          ..open = isOpen;
        index++;
      }
    }
    _grid[2][2].open = true;
    return true;
  }

  Future<void> _saveCardState() async {
    final buffer = StringBuffer();
    for (int col = 0; col < _gridSize; col++) {
      for (int row = 0; row < _gridSize; row++) {
        final cell = _grid[row][col];
        buffer
          ..write(cell.number)
          ..write(':')
          ..write(cell.open)
          ..write(',');
      }
    }
    await Preferences.setCardState(buffer.toString());
  }

  void _onFreeTextChanged(int slot, String value) {
    if (slot == 1) {
      unawaited(Preferences.setFreeText1(value));
    } else {
      unawaited(Preferences.setFreeText2(value));
    }
  }

  Future<void> _speakText(String text) async {
    final trimmed = text.trim();
    if (trimmed.isEmpty) {
      return;
    }
    try {
      await TextToSpeech.stop();
    } catch (_) {
      // Ignore stop errors.
    }
    await TextToSpeech.speak(trimmed);
  }

  int _defaultNumberFor(int row, int col) {
    return col * 15 + row + 1;
  }

  int _indexOf(int row, int col) => col * _gridSize + row;
}

class _CardCell {
  _CardCell({required this.number});

  int number;
  bool open = false;
}

lib/const_value.dart

import 'package:flutter/material.dart';

class ConstValue {
  ConstValue._();

  static const int ballCount = 75;

  static const double minVolumeLevel = 0.0;
  static const double maxVolumeLevel = 1.0;

  static const int minShortTimeNumber = 0;
  static const int maxShortTimeNumber = 9;
  static const int defaultShortTimeNumber = 0;

  static const int minTextSizeTable = 8;
  static const int maxTextSizeTable = 200;
  static const int defaultTextSizeTable = 32;

  static const int minTextSizeCard = 8;
  static const int maxTextSizeCard = 200;
  static const int defaultTextSizeCard = 40;

  static const int minThemeNumber = 0;
  static const int maxThemeNumber = 2;
  static const int defaultThemeNumber = 0;

  static const String prefTtsEnabled = 'ttsEnabled';
  static const String prefTtsVoice = 'ttsVoice';
  static const String prefTtsVoiceId = 'ttsVoiceId';
  static const String prefTtsVolume = 'ttsVolume';
  static const String prefMachineVolume = 'machineVolume';
  static const String prefShortTimeNumber = 'shortTimeNumber';
  static const String prefThemeNumber = 'themeNumber';
  static const String prefTextSizeTable = 'textSizeTable';
  static const String prefTextSizeCard = 'textSizeCard';
  static const String prefLocaleLanguage = 'localeLanguage';
  static const String prefCardState = 'cardState';
  static const String prefBallHistory = 'ballHistory';
  static const String prefFreeText1 = 'freeText1';
  static const String prefFreeText2 = 'freeText2';

  static const ballImage = '''
    <svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
      <defs>
      <radialGradient id="gradation" cx="4207.03" cy="-2361.28" fx="4207.03" fy="-2361.28" r="759.39" gradientTransform="translate(-4275.84 -2532.86) scale(1.14 -1.14)" gradientUnits="userSpaceOnUse">
        <stop offset="0" stop-color="#e7e7e7"/>
        <stop offset=".52" stop-color="#dcdcdc"/>
        <stop offset=".75" stop-color="#d5d5d5"/>
        <stop offset="1" stop-color="#e1e1e1"/>
      </radialGradient>
      <linearGradient id="gradation2" x1="512" y1="402.03" x2="512" y2="965.65" gradientTransform="translate(0 845.89) scale(1 -1)" gradientUnits="userSpaceOnUse">
        <stop offset="0" stop-color="#eee"/>
        <stop offset=".49" stop-color="#f3f3f3"/>
        <stop offset="1" stop-color="#fff"/>
      </linearGradient>
      <linearGradient id="gradation3" x1="-2985.24" y1="2118.12" x2="-2985.24" y2="1929.06" gradientTransform="translate(-2473.24 -1171.35) rotate(-180) scale(1 -1)" gradientUnits="userSpaceOnUse">
        <stop offset="0" stop-color="#e9e9e9"/>
        <stop offset=".09" stop-color="#e8e8e8"/>
        <stop offset="1" stop-color="#e1e1e1"/>
      </linearGradient>
      </defs>
      <path d="M1024,512c0,282.62-229.38,512-512,512S0,794.62,0,512,229.38,0,512,0s512,229.38,512,512Z" fill="url(#gradation)"/>
      <path d="M887.81,324.61c0,145.92-155.14,257.54-375.81,257.54s-375.81-111.62-375.81-257.54S291.84,25.6,512,25.6s375.81,152.58,375.81,299.01Z" fill="url(#gradation2)"/>
      <path d="M245.25,878.59c0-56.83,119.3-102.91,266.75-102.91s266.75,46.08,266.75,102.91-119.3,119.81-266.75,119.81-266.75-62.98-266.75-119.81Z" fill="url(#gradation3)"/>
    </svg>
  ''';
}

lib/empty.dart

lib/home_screen.dart

import 'dart:async';
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:video_player/video_player.dart';

import 'package:bingomachine/l10n/app_localizations.dart';
import 'package:bingomachine/ad_banner_widget.dart';
import 'package:bingomachine/ad_manager.dart';
import 'package:bingomachine/const_value.dart';
import 'package:bingomachine/language_support.dart';
import 'package:bingomachine/loading_screen.dart';
import 'package:bingomachine/preferences.dart';
import 'package:bingomachine/text_to_speech.dart';
import 'package:bingomachine/sound_player.dart';
import 'package:bingomachine/theme_color.dart';
import 'package:bingomachine/setting_page.dart';
import 'package:bingomachine/card_page.dart';
import 'package:bingomachine/main.dart';
import 'package:bingomachine/theme_mode_number.dart';

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

class MainHomePageState extends State<MainHomePage> with TickerProviderStateMixin {
  static const Alignment _ballStartAlignment = Alignment(-0.51, 0.56);

  final AdManager _adManager = AdManager();
  final SoundPlayer _soundPlayer = SoundPlayer();

  VideoPlayerController? _videoController;
  bool _videoReady = false;
  int? _pendingBallIndex;

  AnimationController? _resultController;
  late final AnimationController _ballController;
  late final Animation<double> _ballScale;
  late final Animation<Alignment> _ballAlignment;

  bool _isSpinning = false;
  bool _videoCompleted = false;
  bool _videoStarted = false;
  late ThemeColor _themeColor;
  bool _isReady = false;
  bool _isFirst = true;
  bool _secondImageVisible = true;


  List<int> _ballHistory = <int>[];
  Set<int> _ballHistorySet = <int>{};
  int? _currentBall;

  @override
  void initState() {
    super.initState();
    _initialize();
  }

  @override
  void dispose() {
    _videoController
      ?..removeListener(_onVideoFrame)
      ..dispose();
    _resultController?.dispose();
    _ballController.removeListener(_onBallAnimationTick);
    _ballController.dispose();
    _adManager.dispose();
    _soundPlayer.dispose();
    TextToSpeech.stop();
    super.dispose();
  }

  Future<void> _initialize() async {
    _resultController = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 500),
    );
    _ballController = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 3),
    );
    final CurvedAnimation ballCurve = CurvedAnimation(
      parent: _ballController,
      curve: Curves.easeOut,
    );
    _ballScale = ballCurve;
    _ballAlignment = AlignmentTween(
      begin: _ballStartAlignment,
      end: Alignment.topLeft,
    ).animate(ballCurve);
    _ballController.addListener(_onBallAnimationTick);
    await Preferences.ensureReady();
    await LanguageState.ensureInitialized();
    await _setupVideoController();
    _applyBallHistory();
    await _applyTts();
    /*
    //画像の事前読み込み
    WidgetsBinding.instance.addPostFrameCallback((_) {
      precacheImage(
        const AssetImage('assets/image/first_frame1081.jpg'),
        context,
      );
    });
    */
    if (mounted) {
      setState(() {
        _isReady = true;
      });
    }
  }

  //ball history
  void _applyBallHistory() {
    _ballHistory = _parseHistory(Preferences.ballHistory);
    _ballHistorySet = _ballHistory.toSet();
    if (_ballHistory.isNotEmpty) {
      _currentBall = _ballHistory.last;
      _resultController?.forward(from: 1);
    } else {
      _currentBall = null;
    }
  }
  //tts
  Future<void> _applyTts() async {
    await TextToSpeech.getInstance();
    TextToSpeech.setTtsVoiceId(Preferences.ttsVoiceId);
    await TextToSpeech.setVolume(Preferences.ttsVolume);
    await TextToSpeech.setSpeechVoiceFromId();
  }
  //言語準備
  Future<void> _getCurrentLocale() async {
    final String code = await LanguageState.getLanguageCode();
    if (!mounted) {
      return;
    }
    final mainState = context.findAncestorStateOfType<MainAppState>();
    if (mainState == null) {
      return;
    }
    mainState
      ..localeLanguage = parseLocaleTag(code)
      ..setState(() {});
  }
  //Theme
  void _applyThemeMode() {
    final mainState = context.findAncestorStateOfType<MainAppState>();
    if (mainState == null) {
      return;
    }
    mainState
      ..themeMode = ThemeModeNumber.numberToThemeMode(Preferences.themeNumber)
      ..setState(() {});
  }

  void _ttsResult(String text) async {
    if (Preferences.ttsEnabled && Preferences.ttsVolume > 0.0) {
      await TextToSpeech.speak(text);
    }
  }

  Future<void> _setupVideoController() async {
    final videoController = VideoPlayerController.asset(
      'assets/video/bingo.mp4',
      videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
    );
    _videoController = videoController;
    await videoController.initialize();
    await videoController.setLooping(false);
    await videoController.setPlaybackSpeed((Preferences.shortTimeNumber / 2.0) + 1.0);
    videoController.addListener(_onVideoFrame);
    if (mounted) {
      setState(() {
        _videoReady = true;
      });
    }
  }

  void _onVideoFrame() {
    final videoController = _videoController;
    if (videoController == null ||
        !_isSpinning ||
        _pendingBallIndex == null ||
        _videoCompleted) {
      return;
    }
    final value = videoController.value;
    if (!value.isInitialized) {
      return;
    }
    if (value.isPlaying) {
      _videoStarted = true;
      return;
    }
    if (!_videoStarted) {
      return;
    }
    final bool finished =
        value.duration > Duration.zero &&
        value.position >= value.duration - const Duration(milliseconds: 100);
    if (finished) {
      _onSpinCompleted(_pendingBallIndex!);
    }
  }

  void _onBallAnimationTick() {
    if (!mounted) {
      return;
    }
    setState(() {});
  }

  List<int> _parseHistory(String stored) {
    if (stored.isEmpty) {
      return <int>[];
    }
    final List<int> result = <int>[];
    for (final piece in stored.split(',')) {
      if (piece.isEmpty) continue;
      final value = int.tryParse(piece);
      if (value != null && value >= 0 && value < ConstValue.ballCount) {
        result.add(value);
      }
    }
    return result;
  }

  Future<void> _handleStart() async {
    if (_isSpinning || !_videoReady) {
      return;
    }
    final nextBall = _pickNextBall();
    if (nextBall == null) {
      _notifyFinished();
      return;
    }
    HapticFeedback.selectionClick();
    _resultController?.reset();
    _ballController.reset();
    setState(() {
      _isSpinning = true;
      _pendingBallIndex = nextBall;
      _videoCompleted = false;
      _currentBall = null;
      _videoStarted = false;
    });
    await _soundPlayer.setSpeed((Preferences.shortTimeNumber / 2.0) + 1.0);
    unawaited(_soundPlayer.play(Preferences.machineVolume));
    /*
    //動画再生
    bool initialized = false;
    for (int i = 0; i < 8; i++) {
      try {
        if (!(_videoController?.value.isInitialized ?? false)) {
          await _videoController?.initialize();
        }
        initialized = _videoController?.value.isInitialized ?? false;
        if (initialized) {
          break;
        }
      } catch (e) {}
      await Future.delayed(Duration(milliseconds: 300));
    }
    if (initialized) {
      await _videoController?.setLooping(false);
      await _videoController?.setPlaybackSpeed((Preferences.shortTimeNumber / 2.0) + 1.0);
      await _videoController?.seekTo(Duration.zero);
      await _videoController?.play();
    } else {
      //動画が再生できなかった。動画再生後に移行
      _onSpinCompleted(_pendingBallIndex!);
      //作り直し
      _videoController?.dispose();
      await _setupVideoController();
    }
    */
    //毎回動画を作り直さないと再生されなくなる
    _videoController?.dispose();
    await _setupVideoController();
    await _videoController?.play();
    //0.1秒後に動画に重ねている画像をフェードアウト
    Future.delayed(Duration(milliseconds: 100), () {
      if (mounted) {
        setState(() {
          _secondImageVisible = false;
        });
      }
    });
  }

  int? _pickNextBall() {
    if (_ballHistory.length >= ConstValue.ballCount) {
      return null;
    }
    final remaining = <int>[];
    for (var i = 0; i < ConstValue.ballCount; i++) {
      if (!_ballHistorySet.contains(i)) {
        remaining.add(i);
      }
    }
    if (remaining.isEmpty) {
      return null;
    }
    remaining.shuffle(Random());
    return remaining.first;
  }

  void _onSpinCompleted(int ballIndex) {
    _videoCompleted = true;
    _pendingBallIndex = null;
    _videoController?.pause();
    _soundPlayer.stop();
    final updated = List<int>.from(_ballHistory)..add(ballIndex);
    _ballHistory = updated;
    _ballHistorySet = updated.toSet();
    _currentBall = ballIndex;
    Preferences.setBallHistory(updated.join(','));
    _resultController?.forward(from: 0);
    _ttsResult((ballIndex + 1).toString());
    if (mounted) {
      setState(() {
        _isSpinning = false;
      });
    }
    _ballController.forward(from: 0);
    _secondImageVisible = true;
  }

  void _notifyFinished() {
    final l = AppLocalizations.of(context);
    if (l == null) {
      return;
    }
    ScaffoldMessenger.of(
      context,
    ).showSnackBar(SnackBar(content: Text(l.finished)));
  }

  @override
  Widget build(BuildContext context) {
    if (_isReady == false) {
      return const LoadingScreen();
    }
    if (_isFirst) {
      _isFirst = false;
      _themeColor = ThemeColor(context: context);
    }
    final l = AppLocalizations.of(context)!;
    return Scaffold(
      backgroundColor: _themeColor.mainBackColor,
      body: SafeArea(
        child: Column(
          children: [
            Expanded(
              child: LayoutBuilder(
                builder: (context, constraints) {
                  final bool wideLayout = constraints.maxWidth >= 720;
                  return SingleChildScrollView(
                    padding: const EdgeInsets.only(
                      left: 4,
                      right: 4,
                      top: 0,
                      bottom: 100,
                    ),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.stretch,
                      children: [
                        _buildControlButtons(l),
                        _buildVideoSection(l),
                        if (wideLayout)
                          Row(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Expanded(
                                child: _buildProgressCard(l),
                              ),
                              Expanded(child: _buildHistoryCard(l)),
                            ],
                          )
                        else ...[
                          _buildProgressCard(l),
                          _buildHistoryCard(l),
                        ],
                      ],
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
      bottomNavigationBar: AdBannerWidget(adManager: _adManager),
    );
  }

  Widget _buildControlButtons(AppLocalizations l) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 4),
          child: Row(
            children: [
              Expanded(
                child: OutlinedButton.icon(
                  onPressed: _isSpinning ? null : _openCard,
                  icon: const Icon(Icons.grid_view_rounded),
                  label: Text(l.card),
                  style: OutlinedButton.styleFrom(
                    foregroundColor: _themeColor.mainButtonColor,
                    side: BorderSide(color: _themeColor.mainButtonColor),
                    padding: EdgeInsets.zero,
                    visualDensity: VisualDensity.compact,
                  ),
                ),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: OutlinedButton.icon(
                  onPressed: _isSpinning ? null : _openSettings,
                  icon: const Icon(Icons.settings_rounded),
                  label: Text(l.setting),
                  style: OutlinedButton.styleFrom(
                    foregroundColor: _themeColor.mainButtonColor,
                    side: BorderSide(color: _themeColor.mainButtonColor),
                    padding: EdgeInsets.zero,
                    visualDensity: VisualDensity.compact,
                  ),
                ),
              ),
            ],
          ),
        ),
      ],
    );
  }

  Widget _buildVideoSection(AppLocalizations l) {
    final videoController = _videoController;
    final videoValue = videoController?.value;
    final bool hasVideo = videoValue?.isInitialized ?? false;
    return Card(
      color: _themeColor.cardColor,
      elevation: 0,
      shadowColor: Colors.transparent,
      surfaceTintColor: Colors.transparent,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          ClipRRect(
            borderRadius: BorderRadius.circular(12),
            child: AspectRatio(
              aspectRatio: 1,
              child: LayoutBuilder(
                builder: (context, boxConstraints) {
                  final double videoWidth = boxConstraints.maxWidth;
                  final double ballSize = (videoWidth * 0.5 * _ballScale.value)
                      .clamp(0.0, videoWidth);
                  final bool showBall =
                      _currentBall != null &&
                      (_ballController.value > 0 ||
                          _ballController.isAnimating);
                  return Stack(
                    fit: StackFit.expand,
                    children: [
                      Image.asset('assets/image/last_frame.webp',fit: BoxFit.contain),
                      if (hasVideo && videoController != null)
                        AnimatedOpacity(
                          opacity: 1.0,
                          duration: Duration(milliseconds: 200),
                          child: VideoPlayer(videoController),
                        ),
                      AnimatedOpacity(
                        opacity: _secondImageVisible ? 1.0 : 0.0,
                        duration: Duration(milliseconds: 400),
                        child: Image.asset('assets/image/last_frame.webp', fit: BoxFit.contain),
                      ),
                      if (showBall && ballSize > 0)
                        Padding(
                          padding: const EdgeInsets.only(left: 4, top: 4),
                          child: Align(
                            alignment: _ballAlignment.value,
                            child: SizedBox(
                              width: ballSize,
                              height: ballSize,
                              child: Stack(
                                alignment: Alignment.center,
                                children: [
                                  SvgPicture.string(
                                    ConstValue.ballImage,
                                    width: ballSize,
                                    height: ballSize,
                                  ),
                                  Text(
                                    ((_currentBall ?? 0) + 1).toString(),
                                    style: TextStyle(
                                      fontSize: ballSize * 0.7,
                                      fontWeight: FontWeight.bold,
                                      color: Colors.black,
                                    ),
                                  ),
                                ],
                              ),
                            ),
                          ),
                        ),
                      Align(
                        alignment: Alignment.bottomRight,
                        child: Padding(
                          padding: const EdgeInsets.only(right: 4, bottom: 1),
                          child: ElevatedButton(
                            onPressed: (_isSpinning || !_videoReady)
                                ? null
                                : _handleStart,
                            style: ElevatedButton.styleFrom(
                              padding: const EdgeInsets.symmetric(
                                horizontal: 12,
                                vertical: 4,
                              ),
                              elevation: 0,
                              backgroundColor: _isSpinning
                                  ? Theme.of(context).disabledColor
                                  : _themeColor.mainStartBackColor,
                              foregroundColor: _themeColor.mainStartForeColor,
                              shape: RoundedRectangleBorder(
                                borderRadius: BorderRadius.circular(10),
                              ),
                            ),
                            child: Padding(
                              padding: const EdgeInsets.symmetric(vertical: 1),
                              child: Text(
                                l.start,
                                style: const TextStyle(
                                  fontSize: 20,
                                  fontWeight: FontWeight.bold,
                                ),
                              ),
                            ),
                          ),
                        ),
                      ),
                    ],
                  );
                },
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildProgressCard(AppLocalizations l) {
    return Card(
      color: _themeColor.mainCardColor,
      elevation: 0,
      shadowColor: Colors.transparent,
      surfaceTintColor: Colors.transparent,
      child: Padding(
        padding: const EdgeInsets.all(8),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Text(l.progress, textAlign: TextAlign.center),
            const SizedBox(height: 8),
            _buildProgressGrid(),
          ],
        ),
      ),
    );
  }

  Widget _buildHistoryCard(AppLocalizations l) {
    return Card(
      color: _themeColor.mainCardColor,
      elevation: 0,
      shadowColor: Colors.transparent,
      surfaceTintColor: Colors.transparent,
      child: Padding(
        padding: const EdgeInsets.all(8),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Text(l.history, textAlign: TextAlign.center),
            const SizedBox(height: 8),
            _buildHistoryGrid(),
          ],
        ),
      ),
    );
  }

  Widget _buildProgressGrid() {
    const int columns = 5;
    final int rows = (ConstValue.ballCount / columns).ceil();
    return GridView.builder(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: columns,
        mainAxisSpacing: 3,
        crossAxisSpacing: 3,
        childAspectRatio: 1.6,
      ),
      itemCount: ConstValue.ballCount,
      itemBuilder: (context, index) {
        final int col = index % columns;
        final int row = index ~/ columns;
        final int verticalIndex = row + col * rows;
        final isDrawn = _ballHistorySet.contains(verticalIndex);
        final isLast = _ballHistory.isNotEmpty && _ballHistory.last == verticalIndex;
        final background = isLast
            ? _themeColor.mainTableLastColor
            : (isDrawn ? _themeColor.mainTableOpenColor : _themeColor.mainTableCloseColor);
        return Container(
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: background,
            borderRadius: BorderRadius.circular(6),
          ),
          child: Text(
            (verticalIndex + 1).toString(),
            style: TextStyle(
              fontSize: Preferences.textSizeTable.toDouble(),
              fontWeight: FontWeight.bold,
              color: _themeColor.mainTableTextColor,
            ),
          ),
        );
      },
    );
  }

  Widget _buildHistoryGrid() {
    return GridView.builder(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 5,
        mainAxisSpacing: 3,
        crossAxisSpacing: 3,
        childAspectRatio: 1.6,
      ),
      itemCount: ConstValue.ballCount,
      itemBuilder: (context, index) {
        if (index >= _ballHistory.length) {
          return Container(
            alignment: Alignment.center,
            decoration: BoxDecoration(
              color: _themeColor.mainTableCloseColor,
              borderRadius: BorderRadius.circular(6),
            ),
          );
        }
        final ballIndex = _ballHistory[index];
        final isLast = index == _ballHistory.length - 1;
        final background = isLast ? _themeColor.mainTableLastColor : _themeColor.mainTableOpenColor;
        return Container(
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: background,
            borderRadius: BorderRadius.circular(8),
          ),
          child: Text(
            (ballIndex + 1).toString(),
            style: TextStyle(
              fontSize: Preferences.textSizeTable.toDouble(),
              fontWeight: FontWeight.bold,
              color: _themeColor.mainTableTextColor,
            ),
          ),
        );
      },
    );
  }

  Future<void> _openSettings() async {
    bool? ret = await Navigator.of(context).push(
      MaterialPageRoute<bool>(
        builder: (context) => SettingPage(),
      ),
    );
    if (ret == true) {
      await _getCurrentLocale();
      _applyBallHistory();
      _applyThemeMode();
      await _applyTts();
      if (!mounted) {
        return;
      }
      setState(() {});
    }
  }

  Future<void> _openCard() async {
    await Navigator.of(context).push(
      MaterialPageRoute<bool>(
        builder: (context) => CardPage(),
      ),
    );
  }
}

lib/language_support.dart

///
/// Consolidated language utilities.
///
library;

/// Usage:
///   await LanguageState.ensureInitialized();
///   final currentCode = await LanguageState.getLanguageCode();
///   await LanguageState.setLanguageCode('en');
///   final locale = parseLocaleTag(currentCode);
///
/// Use LanguageCatalog when presenting selectable language names.
/// Configure LanguageState.configure if you need custom storage.
///

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

class LanguageCatalog {
  const LanguageCatalog._();

  static const String prefLanguageCode = 'languageCode';

  static const Map<String, String> names = {
    'en': 'English',
    'bg': 'Български',
    'cs': 'Čeština',
    'da': 'Dansk',
    'de': 'Deutsch',
    'el': 'Ελληνικά',
    'es': 'Español',
    'et': 'Eesti',
    'fi': 'Suomi',
    'fr': 'Français',
    'hu': 'Magyar',
    'it': 'Italiano',
    'ja': '日本語',
    'lt': 'Lietuvių',
    'lv': 'Latviešu',
    'nl': 'Nederlands',
    'pl': 'Polski',
    'pt': 'Português',
    'ro': 'Română',
    'ru': 'Русский',
    'sk': 'Slovenčina',
    'sv': 'Svenska',
    'th': 'ไทย',
    'zh': '中文',
  };

  static List<String> get supportedCodes => names.keys.toList(growable: false);

  static List<Locale> buildSupportedLocales() {
    return supportedCodes.map((code) => Locale(code)).toList(growable: false);
  }

  static String labelFor(String? code) {
    if (code == null || code.isEmpty) {
      return 'Default';
    }
    return names[code] ?? code;
  }
}

abstract class LanguageStorage {
  const LanguageStorage();

  Future<void> saveLanguageCode(String code);

  Future<String> loadLanguageCode();
}

class SharedPreferencesLanguageStorage extends LanguageStorage {
  const SharedPreferencesLanguageStorage({
    this.prefLanguageCode = LanguageCatalog.prefLanguageCode,
  });

  final String prefLanguageCode;

  Future<SharedPreferences> _ensurePrefs() async {
    return SharedPreferences.getInstance();
  }

  @override
  Future<void> saveLanguageCode(String code) async {
    final prefs = await _ensurePrefs();
    await prefs.setString(prefLanguageCode, code);
  }

  @override
  Future<String> loadLanguageCode() async {
    final prefs = await _ensurePrefs();
    return prefs.getString(prefLanguageCode) ?? '';
  }
}

class LanguageState {
  LanguageState._();

  static LanguageStorage _storage = const SharedPreferencesLanguageStorage();
  static String _languageCode = '';
  static bool _initialized = false;
  static Completer<void>? _initializing;

  static String get currentCode => _languageCode;

  static void configure({LanguageStorage? storage, String? initialCode}) {
    if (storage != null) {
      _storage = storage;
    }
    if (initialCode != null) {
      _languageCode = initialCode;
      _initialized = true;
    }
  }

  static Future<void> ensureInitialized() async {
    if (_initialized) {
      return;
    }
    final completer = _initializing;
    if (completer != null) {
      await completer.future;
      return;
    }
    final newCompleter = Completer<void>();
    _initializing = newCompleter;
    try {
      _languageCode = await _storage.loadLanguageCode();
      _initialized = true;
      newCompleter.complete();
    } catch (error, stackTrace) {
      if (!newCompleter.isCompleted) {
        newCompleter.completeError(error, stackTrace);
      }
      rethrow;
    } finally {
      _initializing = null;
    }
  }

  static Future<void> setLanguageCode(String? code) async {
    final value = code?.trim() ?? '';
    _languageCode = value;
    _initialized = true;
    await _storage.saveLanguageCode(value);
  }

  static Future<String> getLanguageCode() async {
    await ensureInitialized();
    return _languageCode;
  }
}

Locale? parseLocaleTag(String tag) {
  if (tag.isEmpty) {
    return null;
  }
  final parts = tag.split('-');
  final language = parts.isNotEmpty ? parts[0] : tag;
  String? script;
  String? country;
  if (parts.length >= 2) {
    final p1 = parts[1];
    if (p1.length == 4) {
      script = p1;
    } else {
      country = p1;
    }
  }
  if (parts.length >= 3) {
    final p2 = parts[2];
    if (p2.length == 4) {
      script = p2;
    } else {
      country = p2;
    }
  }
  return Locale.fromSubtags(
    languageCode: language,
    scriptCode: script,
    countryCode: country,
  );
}

lib/loading_screen.dart

import 'package:flutter/material.dart';

class LoadingScreen extends StatelessWidget {
  const LoadingScreen({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.green,
      body: const Center(
        child: CircularProgressIndicator(
          valueColor: AlwaysStoppedAnimation<Color>(Colors.lightGreenAccent),
          backgroundColor: Colors.white,
        ),
      ),
    );
  }
}

lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:google_mobile_ads/google_mobile_ads.dart'
    if (dart.library.html) 'empty.dart';
import 'package:package_info_plus/package_info_plus.dart';

import 'package:bingomachine/l10n/app_localizations.dart';
import 'package:bingomachine/language_support.dart';
import 'package:bingomachine/preferences.dart';
import 'package:bingomachine/version_state.dart';
import 'package:bingomachine/home_screen.dart';
import 'package:bingomachine/theme_mode_number.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  if (!kIsWeb) {
    MobileAds.instance.initialize();
  }
  await Preferences.ensureReady();
  await LanguageState.ensureInitialized();
  final info = await PackageInfo.fromPlatform();
  VersionState.versionSave(info.version);
  runApp(const MainApp());
}

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

class MainAppState extends State<MainApp> {
  Locale? localeLanguage;
  ThemeMode themeMode = ThemeMode.system;

  @override
  void initState() {
    super.initState();
    localeLanguage = parseLocaleTag(LanguageState.currentCode);
    themeMode = ThemeModeNumber.numberToThemeMode(Preferences.themeNumber);
  }

  @override
  Widget build(BuildContext context) {
    const seed = Color(0xFF29E282);
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      locale: localeLanguage,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: seed),
        useMaterial3: true,
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: seed,
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      themeMode: themeMode,
      home: const MainHomePage(),
    );
  }
}

lib/preferences.dart

import 'package:shared_preferences/shared_preferences.dart';

import 'package:bingomachine/const_value.dart';
import 'package:bingomachine/language_support.dart';

class Preferences {
  Preferences._();

  static bool _ready = false;
  static bool _ttsEnabled = true;
  static String _ttsVoiceId = '';
  static double _ttsVolume = ConstValue.maxVolumeLevel;
  static double _machineVolume = ConstValue.maxVolumeLevel;
  static int _shortTimeNumber = 0;
  static int _themeNumber = 0;
  static int _textSizeTable = ConstValue.defaultTextSizeTable;
  static int _textSizeCard = ConstValue.defaultTextSizeCard;
  static String _localeLanguage = '';
  static String _cardState = '';
  static String _freeText1 = 'Reach';
  static String _freeText2 = 'Bingo';
  static String _ballHistory = '';

  static bool get ready => _ready;
  static bool get ttsEnabled => _ttsEnabled;
  static String get ttsVoiceId => _ttsVoiceId;
  static double get ttsVolume => _ttsVolume;
  static double get machineVolume => _machineVolume;
  static int get shortTimeNumber => _shortTimeNumber;
  static int get themeNumber => _themeNumber;
  static int get textSizeTable => _textSizeTable;
  static int get textSizeCard => _textSizeCard;
  static String get localeLanguage => _localeLanguage;
  static String get cardState => _cardState;
  static String get freeText1 => _freeText1;
  static String get freeText2 => _freeText2;
  static String get ballHistory => _ballHistory;

  static Future<void> ensureReady() async {
    if (_ready) {
      return;
    }
    final prefs = await SharedPreferences.getInstance();
    _ttsEnabled = prefs.getBool(ConstValue.prefTtsEnabled) ?? true;
    _ttsVoiceId = prefs.getString(ConstValue.prefTtsVoiceId) ?? '';
    _ttsVolume = (prefs.getDouble(ConstValue.prefTtsVolume) ?? ConstValue.maxVolumeLevel).clamp(
      ConstValue.minVolumeLevel,
      ConstValue.maxVolumeLevel,
    );
    _machineVolume = (prefs.getDouble(ConstValue.prefMachineVolume) ?? ConstValue.maxVolumeLevel).clamp(
      ConstValue.minVolumeLevel,
      ConstValue.maxVolumeLevel,
    );
    _shortTimeNumber = (prefs.getInt(ConstValue.prefShortTimeNumber) ?? ConstValue.defaultShortTimeNumber).clamp(
      ConstValue.minShortTimeNumber,
      ConstValue.maxShortTimeNumber,
    );
    _themeNumber = (prefs.getInt(ConstValue.prefThemeNumber) ?? ConstValue.defaultThemeNumber).clamp(
      ConstValue.minThemeNumber,
      ConstValue.maxThemeNumber,
    );
    _textSizeTable = (prefs.getInt(ConstValue.prefTextSizeTable) ?? ConstValue.defaultTextSizeTable).clamp(
      ConstValue.minTextSizeTable,
      ConstValue.maxTextSizeTable,
    );
    _textSizeCard = (prefs.getInt(ConstValue.prefTextSizeCard) ?? ConstValue.defaultTextSizeCard).clamp(
      ConstValue.minTextSizeCard,
      ConstValue.maxTextSizeCard,
    );
    final storedLocale = prefs.getString(ConstValue.prefLocaleLanguage) ?? '';
    _cardState = prefs.getString(ConstValue.prefCardState) ?? '';
    _freeText1 = prefs.getString(ConstValue.prefFreeText1) ?? 'Reach';
    _freeText2 = prefs.getString(ConstValue.prefFreeText2) ?? 'Bingo';
    _ballHistory = prefs.getString(ConstValue.prefBallHistory) ?? '';

    await LanguageState.ensureInitialized();
    final languageCode = LanguageState.currentCode;
    if (languageCode.isEmpty) {
      _localeLanguage = storedLocale;
      if (storedLocale.isNotEmpty) {
        await LanguageState.setLanguageCode(storedLocale);
      }
    } else {
      _localeLanguage = languageCode;
      if (storedLocale != languageCode) {
        await prefs.setString(ConstValue.prefLocaleLanguage, languageCode);
      }
    }
    _ready = true;
  }

  static Future<void> resetMachine() async {
    _ballHistory = '';
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove(ConstValue.prefBallHistory);
  }

  static Future<void> resetCard() async {
    _cardState = '';
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove(ConstValue.prefCardState);
  }

  static Future<void> setTtsEnabled(bool flag) async {
    _ttsEnabled = flag;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(ConstValue.prefTtsEnabled, flag);
  }

  static Future<void> setTtsVoiceId(String value) async {
    _ttsVoiceId = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(ConstValue.prefTtsVoiceId, value);
  }

  static Future<void> setTtsVolume(double value) async {
    _ttsVolume = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setDouble(ConstValue.prefTtsVolume, value);
  }

  static Future<void> setMachineVolume(double value) async {
    _machineVolume = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setDouble(ConstValue.prefMachineVolume, value);
  }

  static Future<void> setShortTimeNumber(int value) async {
    _shortTimeNumber = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt(ConstValue.prefShortTimeNumber, value);
  }

  static Future<void> setThemeNumber(int value) async {
    _themeNumber = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt(ConstValue.prefThemeNumber, value);
  }

  static Future<void> setTextSizeTable(int value) async {
    _textSizeTable = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt(ConstValue.prefTextSizeTable, value);
  }

  static Future<void> setTextSizeCard(int value) async {
    _textSizeCard = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt(ConstValue.prefTextSizeCard, value);
  }

  static Future<void> setLocaleLanguage(String value) async {
    _localeLanguage = value;
    await LanguageState.setLanguageCode(value.isEmpty ? null : value);
    final prefs = await SharedPreferences.getInstance();
    if (value.isEmpty) {
      await prefs.remove(ConstValue.prefLocaleLanguage);
    } else {
      await prefs.setString(ConstValue.prefLocaleLanguage, value);
    }
  }

  static Future<void> setCardState(String value) async {
    _cardState = value;
    final prefs = await SharedPreferences.getInstance();
    if (value.isEmpty) {
      await prefs.remove(ConstValue.prefCardState);
    } else {
      await prefs.setString(ConstValue.prefCardState, value);
    }
  }

  static Future<void> setFreeText1(String value) async {
    _freeText1 = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(ConstValue.prefFreeText1, value);
  }

  static Future<void> setFreeText2(String value) async {
    _freeText2 = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(ConstValue.prefFreeText2, value);
  }

  static Future<void> setBallHistory(String value) async {
    _ballHistory = value;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(ConstValue.prefBallHistory, value);
  }

}

lib/setting_page.dart

import 'dart:async';

import 'package:flutter/material.dart';

import 'package:bingomachine/l10n/app_localizations.dart';
import 'package:bingomachine/ad_banner_widget.dart';
import 'package:bingomachine/ad_manager.dart';
import 'package:bingomachine/const_value.dart';
import 'package:bingomachine/language_support.dart';
import 'package:bingomachine/loading_screen.dart';
import 'package:bingomachine/preferences.dart';
import 'package:bingomachine/text_to_speech.dart';
import 'package:bingomachine/theme_color.dart';
import 'package:bingomachine/version_state.dart';

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

class _SettingPageState extends State<SettingPage> {
  late final AdManager _adManager;
  String? _languageKey; //言語コード(null はデフォルト)
  bool _resetMachine = false;
  bool _resetCard = false;
  int _themeNumber = 0;
  int _shortTimeNumber = ConstValue.defaultShortTimeNumber;
  int _textSizeTable = ConstValue.defaultTextSizeTable;
  int _textSizeCard = ConstValue.defaultTextSizeCard;
  late List<TtsOption> _ttsVoices;
  bool _ttsEnabled = true;
  String _ttsVoiceId = '';
  double _ttsVolume = ConstValue.maxVolumeLevel;
  double _machineVolume = ConstValue.maxVolumeLevel;
  late ThemeColor _themeColor;
  bool _isReady = false;
  bool _isFirst = true;

  @override
  void initState() {
    super.initState();
    _initialize();
  }

  Future<void> _initialize() async {
    _adManager = AdManager();
    final String languageCode = await LanguageState.getLanguageCode();
    _languageKey = languageCode.isEmpty ? null : languageCode;
    //
    await Preferences.ensureReady();
    await LanguageState.ensureInitialized();
    //
    _ttsEnabled = Preferences.ttsEnabled;
    _ttsVolume = Preferences.ttsVolume;
    _ttsVoiceId = Preferences.ttsVoiceId;
    _machineVolume = Preferences.machineVolume;
    _shortTimeNumber = Preferences.shortTimeNumber;
    _themeNumber = Preferences.themeNumber;
    _textSizeTable = Preferences.textSizeTable;
    _textSizeCard = Preferences.textSizeCard;
    //speech
    await TextToSpeech.getInstance();
    _ttsVoices = TextToSpeech.ttsVoices;
    TextToSpeech.setVolume(_ttsVolume);
    TextToSpeech.setTtsVoiceId(_ttsVoiceId);
    //
    setState(() {
      _isReady = true;
    });
  }

  @override
  void dispose() {
    _adManager.dispose();
    unawaited(TextToSpeech.stop());
    super.dispose();
  }

  Future<void> _onApply() async {
    FocusScope.of(context).unfocus();
    if (_resetMachine) {
      await Preferences.resetMachine();
    }
    if (_resetCard) {
      await Preferences.resetCard();
    }
    await LanguageState.setLanguageCode(_languageKey);
    await Preferences.setTtsEnabled(_ttsEnabled);
    await Preferences.setTtsVoiceId(_ttsVoiceId);
    await Preferences.setTtsVolume(_ttsVolume);
    await Preferences.setMachineVolume(_machineVolume);
    await Preferences.setShortTimeNumber(_shortTimeNumber);
    await Preferences.setTextSizeTable(_textSizeTable);
    await Preferences.setTextSizeCard(_textSizeCard);
    await Preferences.setThemeNumber(_themeNumber);
    if (!mounted) {
      return;
    }
    Navigator.of(context).pop(true);
  }

  @override
  Widget build(BuildContext context) {
    if (!_isReady) {
      return const LoadingScreen();
    }
    if (_isFirst) {
      _isFirst = false;
      _themeColor = ThemeColor(themeNumber: _themeNumber, context: context);
    }
    final l = AppLocalizations.of(context)!;
    return Scaffold(
      backgroundColor: _themeColor.backColor,
      appBar: AppBar(
        centerTitle: true,
        elevation: 0,
        leading: IconButton(
          icon: const Icon(Icons.close),
          onPressed: () => Navigator.of(context).pop(false),
        ),
        title: Text(l.setting),
        foregroundColor: _themeColor.appBarForegroundColor,
        backgroundColor: Colors.transparent,
        actions: [
          IconButton(icon: const Icon(Icons.check), onPressed: _onApply),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: GestureDetector(
              onTap: () => FocusScope.of(context).unfocus(),
              child: SingleChildScrollView(
                padding: const EdgeInsets.only(
                  left: 4,
                  top: 4,
                  right: 4,
                  bottom: 100,
                ),
                child: Column(
                  children: [
                    _buildResetSection(l),
                    _buildSpeechSettings(l),
                    _buildVolumeSection(l),
                    _buildShortTimeNumberSection(l),
                    _buildTextSizeSection(l),
                    _buildLanguage(l),
                    _buildThemeSection(l),
                    _buildUsageSection(l),
                    _buildVersionSection(),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
      bottomNavigationBar: AdBannerWidget(adManager: _adManager),
    );
  }

  Widget _buildResetSection(AppLocalizations l) {
    return Column(children:[
      Card(
        margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(12),
            topRight: Radius.circular(12),
            bottomLeft: Radius.circular(0),
            bottomRight: Radius.circular(0),
          ),
        ),
        color: _themeColor.cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              SwitchListTile.adaptive(
                contentPadding: EdgeInsets.zero,
                title: Text(
                  l.resetMachine,
                  style: Theme.of(context).textTheme.bodyMedium,
                ),
                subtitle: Text(
                  l.resetMachineNote,
                  style: Theme.of(context).textTheme.bodySmall,
                ),
                value: _resetMachine,
                onChanged: (value) {
                  setState(() {
                    _resetMachine = value;
                  });
                },
              ),
            ],
          ),
        ),
      ),
      Card(
        margin: const EdgeInsets.only(left: 4, top: 2, right: 4, bottom: 0),
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(0),
            topRight: Radius.circular(0),
            bottomLeft: Radius.circular(12),
            bottomRight: Radius.circular(12),
          ),
        ),
        color: _themeColor.cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              SwitchListTile.adaptive(
                contentPadding: EdgeInsets.zero,
                title: Text(
                  l.resetCard,
                  style: Theme.of(context).textTheme.bodyMedium,
                ),
                subtitle: Text(
                  l.resetCardNote,
                  style: Theme.of(context).textTheme.bodySmall,
                ),
                value: _resetCard,
                onChanged: (value) {
                  setState(() {
                    _resetCard = value;
                  });
                },
              ),
            ],
          ),
        ),
      )
    ]);
  }

  Widget _buildSpeechSettings(AppLocalizations l) {
    if (_ttsVoices.isEmpty) {
      return SizedBox.shrink();
    }
    final l = AppLocalizations.of(context)!;
    return Column(children:[
      Card(
        margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(12),
            topRight: Radius.circular(12),
            bottomLeft: Radius.circular(0),
            bottomRight: Radius.circular(0),
          ),
        ),
        color: _themeColor.cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
              child: Row(
                children: [
                  Expanded(
                    child: Text(
                      l.ttsEnabled,
                    ),
                  ),
                  Switch(
                    value: _ttsEnabled,
                    onChanged: (bool value) {
                      setState(() {
                        _ttsEnabled = value;
                      });
                    },
                  ),
                ],
              ),
            ),
          ],
        )
      ),
      Card(
        margin: const EdgeInsets.only(left: 4, top: 2, right: 4, bottom: 0),
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(0),
            topRight: Radius.circular(0),
            bottomLeft: Radius.circular(0),
            bottomRight: Radius.circular(0),
          ),
        ),
        color: _themeColor.cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.only(left: 16, right: 16, top: 12),
              child: Row(
                children: [
                  Text(
                    l.ttsVolume,
                  ),
                  const Spacer(),
                ],
              ),
            ),
            Padding(
              padding: const EdgeInsets.only(left: 16, right: 16),
              child: Row(
                children: <Widget>[
                  Text(_ttsVolume.toStringAsFixed(1)),
                  Expanded(
                    child: Slider(
                      value: _ttsVolume,
                      min: ConstValue.minVolumeLevel,
                      max: ConstValue.maxVolumeLevel,
                      divisions: 10,
                      label: _ttsVolume.toStringAsFixed(1),
                      onChanged: _ttsEnabled
                        ? (double value) {
                          setState(() {
                            _ttsVolume = double.parse(
                              value.toStringAsFixed(1),
                            );
                          });
                        }
                        : null,
                    ),
                  ),
                ],
              ),
            ),
          ],
        )
      ),
      Card(
        margin: const EdgeInsets.only(left: 4, top: 2, right: 4, bottom: 0),
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(0),
            topRight: Radius.circular(0),
            bottomLeft: Radius.circular(12),
            bottomRight: Radius.circular(12),
          ),
        ),
        color: _themeColor.cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.only(left: 16, right: 16, top: 4, bottom: 16),
              child: DropdownButtonFormField<String>(
                initialValue: () {
                  if (_ttsVoiceId.isNotEmpty && _ttsVoices.any((o) => o.id == _ttsVoiceId)) {
                    return _ttsVoiceId;
                  }
                  return _ttsVoices.first.id;
                }(),
                items: _ttsVoices
                    .map((o) => DropdownMenuItem<String>(value: o.id, child: Text(o.label)))
                    .toList(),
                onChanged: (v) {
                  if (v == null) return;
                  setState(() => _ttsVoiceId = v);
                },
              ),
            ),
          ],
        )
      )
    ]);
  }

  Widget _buildVolumeSection(AppLocalizations l) {
    return Card(
      margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
      color: _themeColor.cardColor,
      elevation: 0,
      shadowColor: Colors.transparent,
      surfaceTintColor: Colors.transparent,
      child: Padding(
        padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(l.machineVolume),
                Row(
                  children: [
                    Text(
                      _machineVolume.toStringAsFixed(1),
                      textAlign: TextAlign.right,
                    ),
                    Expanded(
                      child: Slider(
                        value: _machineVolume,
                        min: ConstValue.minVolumeLevel,
                        max: ConstValue.maxVolumeLevel,
                        divisions: 10,
                        label: _machineVolume.toStringAsFixed(1),
                        onChanged: (value) {
                          setState(() {
                            _machineVolume = value;
                          });
                        },
                      ),
                    ),
                  ],
                ),
              ],
            )
          ],
        ),
      ),
    );
  }

  Widget _buildShortTimeNumberSection(AppLocalizations l) {
    return Card(
      margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
      color: _themeColor.cardColor,
      elevation: 0,
      shadowColor: Colors.transparent,
      surfaceTintColor: Colors.transparent,
      child: Padding(
        padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(l.shortTimeNumber),
            Row(
              children: [
                Text(
                  _shortTimeNumber.toStringAsFixed(1),
                  textAlign: TextAlign.right,
                ),
                Expanded(
                  child: Slider(
                    value: _shortTimeNumber.toDouble(),
                    min: ConstValue.minShortTimeNumber.toDouble(),
                    max: ConstValue.maxShortTimeNumber.toDouble(),
                    divisions: 10,
                    label: _shortTimeNumber.toStringAsFixed(1),
                    onChanged: (value) {
                      setState(() {
                        _shortTimeNumber = value.toInt();
                      });
                    },
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildTextSizeSection(AppLocalizations l) {
    return Column(children:[
      Card(
        margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(12),
            topRight: Radius.circular(12),
            bottomLeft: Radius.circular(0),
            bottomRight: Radius.circular(0),
          ),
        ),
        color: _themeColor.cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: Padding(
          padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(l.textSizeTable),
              Row(
                children: [
                  SizedBox(
                    width: 30,
                    child: Text(
                      _textSizeTable.toStringAsFixed(0),
                      textAlign: TextAlign.right,
                    ),
                  ),
                  Expanded(
                    child: Slider(
                      value: _textSizeTable.toDouble(),
                      min: ConstValue.minTextSizeTable.toDouble(),
                      max: ConstValue.maxTextSizeTable.toDouble(),
                      divisions: 24,
                      label: _textSizeTable.toStringAsFixed(0),
                      onChanged: (value) {
                        setState(() {
                          _textSizeTable = value.toInt();
                        });
                      },
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
      Card(
        margin: const EdgeInsets.only(left: 4, top: 2, right: 4, bottom: 0),
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.circular(0),
            topRight: Radius.circular(0),
            bottomLeft: Radius.circular(12),
            bottomRight: Radius.circular(12),
          ),
        ),
        color: _themeColor.cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: Padding(
          padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(l.textSizeCard),
              Row(
                children: [
                  SizedBox(
                    width: 30,
                    child: Text(
                      _textSizeCard.toStringAsFixed(0),
                      textAlign: TextAlign.right,
                    ),
                  ),
                  Expanded(
                    child: Slider(
                      value: _textSizeCard.toDouble(),
                      min: ConstValue.minTextSizeCard.toDouble(),
                      max: ConstValue.maxTextSizeCard.toDouble(),
                      divisions: 24,
                      label: _textSizeCard.toStringAsFixed(0),
                      onChanged: (value) {
                        setState(() {
                          _textSizeCard = value.toInt();
                        });
                      },
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      )
    ]);
  }

  Widget _buildLanguage(AppLocalizations l) {
    return Card(
      margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
      color: _themeColor.cardColor,
      elevation: 0,
      shadowColor: Colors.transparent,
      surfaceTintColor: Colors.transparent,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            ListTile(
              title: Text(l.language,style: Theme.of(context).textTheme.bodyMedium),
              contentPadding: const EdgeInsets.symmetric(horizontal: 0),
              trailing: DropdownButton<String?>(
                value: _languageKey,
                items: [
                  const DropdownMenuItem<String?>(
                    value: null,
                    child: Text('Default'),
                  ),
                  ...LanguageCatalog.names.entries.map(
                        (entry) => DropdownMenuItem<String?>(
                      value: entry.key,
                      child: Text(entry.value),
                    ),
                  ),
                ],
                onChanged: (String? value) {
                  setState(() {
                    _languageKey = value;
                  });
                },
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildThemeSection(AppLocalizations l) {
    return Card(
      margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
      color: _themeColor.cardColor,
      elevation: 0,
      shadowColor: Colors.transparent,
      surfaceTintColor: Colors.transparent,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            ListTile(
              title: Text(
                l.theme,
                style: Theme.of(context).textTheme.bodyMedium,
              ),
              contentPadding: EdgeInsets.zero,
              trailing: DropdownButton<int>(
                value: _themeNumber,
                items: [
                  DropdownMenuItem(value: 0, child: Text(l.systemDefault)),
                  DropdownMenuItem(value: 1, child: Text(l.lightTheme)),
                  DropdownMenuItem(value: 2, child: Text(l.darkTheme)),
                ],
                onChanged: (value) {
                  if (value == null) {
                    return;
                  }
                  setState(() {
                    _themeNumber = value;
                  });
                },
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildUsageSection(AppLocalizations l) {
    final bodyStyle = Theme.of(context).textTheme.bodyMedium;
    final noteStyle = Theme.of(context).textTheme.bodySmall;

    return SizedBox(
      width: double.infinity,
      child: Card(
        margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 0),
        color: _themeColor.cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(l.usageTitle, style: bodyStyle),
              const SizedBox(height: 8),
              Text(l.usageDescription, style: noteStyle),
              const SizedBox(height: 16),
              Text(l.usageNote, style: noteStyle),
              const SizedBox(height: 16),
              Text(l.usageHostTitle, style: bodyStyle),
              const SizedBox(height: 8),
              Text(l.usageHostDescription, style: noteStyle),
              const SizedBox(height: 16),
              Text(l.usagePlayerTitle, style: bodyStyle),
              const SizedBox(height: 8),
              Text(l.usagePlayerDescription, style: noteStyle),
            ],
          ),
        ),
      )
    );
  }

  Widget _buildVersionSection() {
    return SizedBox(
      width: double.infinity,
      child: Card(
        margin: const EdgeInsets.only(left: 4, top: 12, right: 4, bottom: 16),
        color: _themeColor.cardColor,
        elevation: 0,
        shadowColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        child: Padding(
          padding: const EdgeInsets.symmetric(vertical: 16),
          child: Center(
            child: Text(
              'version  ${VersionState.versionLoad()}',
              style: const TextStyle(fontSize: 10),
            ),
          ),
        ),
      )
    );
  }

}

lib/sound_player.dart

import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';

/// Lightweight wrapper around [AudioPlayer] for the bingo spin sound.
class SoundPlayer {
  SoundPlayer() {
    _load();
  }

  final AudioPlayer _player = AudioPlayer();
  bool _loaded = false;

  Future<void> _load() async {
    final session = await AudioSession.instance;
    await session.configure(AudioSessionConfiguration.speech().copyWith(
      androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient,
      androidWillPauseWhenDucked: false,
      avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.mixWithOthers,
    ));
    try {
      await _player.setAsset('assets/audio/karakara.wav');
      await _player.load();
      _loaded = true;
    } catch (_) {
      _loaded = false;
    }
  }

  Future<void> play(double volume) async {
    if (!_loaded) {
      await _load();
    }
    await _player.setVolume(volume.clamp(0.0, 1.0));
    await _player.seek(Duration.zero);
    await _player.play();
  }

  Future<void> setSpeed(double speed) async {
    await _player.setSpeed(speed);
  }

  Future<void> stop() async {
    if (_player.playing) {
      await _player.stop();
    }
  }

  Future<void> dispose() async {
    await _player.dispose();
  }
}

lib/text_to_speech.dart

import 'package:flutter_tts/flutter_tts.dart';
import 'dart:io' show Platform;
import 'package:collection/collection.dart';

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

//外部からの利用方法
//await TextToSpeech.getInstance();
//_ttsVoices = TextToSpeech.ttsVoices;
//TextToSpeech.setTtsVoiceId(_ttsVoiceId);
//etc.
class TextToSpeech {
  static late FlutterTts _tts;
  static final List<TtsOption> ttsVoices = [];
  static String ttsVoiceId = '';

  static TextToSpeech? _instance;
  static bool _initialized = false;

  TextToSpeech._internal();

  static Future<TextToSpeech> getInstance() async {
    _instance ??= TextToSpeech._internal();
    if (!_initialized) {
      await _instance!._initial();
      _initialized = true;
    }
    return _instance!;
  }

  //声リスト作成
  Future<void> _initial() async {
    _tts = FlutterTts();
    try {
      List<dynamic>? vs;
      for (int i = 0; i < 10; i++) {
        vs = await _tts.getVoices;
        if (vs != null) {
          break;
        }
        await Future.delayed(Duration(seconds: 1));
      }
      if (vs is List) {
        ttsVoices.clear();
        for (final v in vs) {
          if (v is Map && v['name'] is String && v['locale'] is String) {
            ttsVoices.add(TtsOption(v['locale']!, v['name']!));
          }
        }
      }
      ttsVoices.sort((a, b) => a.label.compareTo(b.label));
      ttsVoices.insert(0, TtsOption("Default", ""));
      ttsVoiceId = ttsVoices.first.id;
      await _tts.awaitSpeakCompletion(true);
    } catch (_) {}
  }

  //ttsVoiceIdを登録
  static void setTtsVoiceId(String newTtsVoiceId) {
    final exists = ttsVoices.any((o) => o.id == newTtsVoiceId);
    if (exists) {
      ttsVoiceId = newTtsVoiceId;
    } else {
      ttsVoiceId = ttsVoices.first.id;
    }
  }

  //ttsVoiceIdの声を用意
  static Future<void> setSpeechVoiceFromId() async {
    if (ttsVoices.isEmpty || ttsVoiceId.isEmpty) {
      return;
    }
    final idx = ttsVoiceId.indexOf('|');
    String selLocale = '';
    String selName = ttsVoiceId;
    if (idx >= 0) {
      selLocale = ttsVoiceId.substring(0, idx);
      selName = ttsVoiceId.substring(idx + 1);
    }
    TtsOption? match;
    if (selLocale.isNotEmpty) {
      match = ttsVoices.firstWhereOrNull(
        (e) => e.name == selName && e.locale == selLocale,
      );
    }
    match ??= ttsVoices.firstWhereOrNull((e) => e.name == selName);
    if (match != null) {
      final locale = match.locale;
      final name = match.name;
      try {
        if (Platform.isAndroid) {
          // Prefer Google TTS if available; ignore errors if not installed
          try {
            await _tts.setEngine('com.google.android.tts');
          } catch (_) {}
          if (locale.isNotEmpty) {
            await _tts.setLanguage(locale);
          }
          await _tts.setVoice({'name': name, 'locale': locale});
        } else if (Platform.isIOS) {
          // On iOS, setting voice is sufficient; avoid setLanguage overriding the voice
          await _tts.setVoice({'name': name, 'locale': locale});
        } else {
          // Fallback for other platforms
          if (locale.isNotEmpty) {
            await _tts.setLanguage(locale);
          }
          await _tts.setVoice({'name': name, 'locale': locale});
        }
      } catch (_) {}
    }
  }

  //文字列を音声再生
  static Future<void> speak(String text) async {
    try {
      await _tts.stop();
      await _tts.speak(text);
    } catch (_) {}
  }

  //音声再生を停止
  static Future<void> stop() async {
    try {
      await _tts.stop();
    } catch (_) {}
  }

  //音声再生の速度
  static Future<void> setVolume(double volume) async {
    try {
      await _tts.setVolume(volume);
    } catch (_) {}
  }

  //音声の高さ
  static Future<void> setPitch(double pitch) async {
    try {
      await _tts.setPitch(pitch);
    } catch (_) {}
  }

  //音声の速度
  static Future<void> setSpeechRate(double speechRate) async {
    try {
      await _tts.setSpeechRate(speechRate);
    } catch (_) {}
  }
}

lib/theme_color.dart

import 'package:flutter/material.dart';

class ThemeColor {
  final int? themeNumber;
  final BuildContext context;

  ThemeColor({this.themeNumber, required this.context});

  Brightness get _effectiveBrightness {
    switch (themeNumber) {
      case 1:
        return Brightness.light;
      case 2:
        return Brightness.dark;
      default:
        return Theme.of(context).brightness;
    }
  }

  bool _isLight() {
    return _effectiveBrightness == Brightness.light;
  }

  Color get mainBackColor => _isLight() ? Color.fromRGBO(46,255,146,1) : Color.fromRGBO(0,90,10,1);
  Color get mainButtonColor => _isLight() ? Color.fromRGBO(0,0,0,0.3) : Color.fromRGBO(255,255,255,0.3);
  Color get mainStartBackColor => _isLight() ? Color.fromRGBO(255,255,255,0.3) : Color.fromRGBO(255,255,255,0.3);
  Color get mainStartForeColor => _isLight() ? Color.fromRGBO(0,0,0,0.6) : Color.fromRGBO(255,255,255,0.8);
  Color get mainCardColor => _isLight() ? Color.fromRGBO(255,255,255,0.5) : Color.fromRGBO(255,255,255,0.2);
  Color get mainTableCloseColor => _isLight() ? Color.fromRGBO(46,255,146,1) : Color.fromRGBO(0,0,0,0.5);
  Color get mainTableOpenColor => _isLight() ? Color.fromRGBO(255,212,0,1) : Color.fromRGBO(255,183,0,0.6);
  Color get mainTableLastColor => _isLight() ? Color.fromRGBO(255,110,0,1) : Color.fromRGBO(255,110,0,0.9);
  Color get mainTableTextColor => _isLight() ? Color.fromRGBO(0,0,0,1) : Color.fromRGBO(255,255,255,0.7);

  Color get cardBackColor => _isLight() ? Color.fromRGBO(255, 244, 204, 1.0) : Color.fromRGBO(62, 42, 0, 1.0);
  Color get cardTitleColor => _isLight() ? Color.fromRGBO(0,0,0,0.8) : Color.fromRGBO(255,255,255,0.9);
  Color get cardSubjectBackColor => _isLight() ? Color.fromRGBO(255,255,255,0.8) : Color.fromRGBO(0,0,0,0.3);
  Color get cardSubjectForeColor => _isLight() ? Color.fromRGBO(0,0,0,0.6) : Color.fromRGBO(255,255,255,0.4);
  Color get cardTableCloseBackColor => _isLight() ? Color.fromRGBO(0, 239, 119, 1.0) : Color.fromRGBO(0,0,0,0.4);
  Color get cardTableOpenBackColor => _isLight() ? Color.fromRGBO(255, 212, 0, 1.0) : Color.fromRGBO(214, 148, 0, 0.6);
  Color get cardTableCloseForeColor => _isLight() ? Color.fromRGBO(0,0,0,1) : Color.fromRGBO(255,255,255,0.9);
  Color get cardTableOpenForeColor => _isLight() ? Color.fromRGBO(0,0,0,1) : Color.fromRGBO(255,255,255,1);
  Color get cardTableBingoForeColor => _isLight() ? Color.fromRGBO(255, 77, 0, 1.0) : Color.fromRGBO(
      255, 221, 0, 1.0);

  Color get backColor => _isLight() ? Colors.grey[200]! : Colors.grey[900]!;
  Color get cardColor => _isLight() ? Colors.white : Colors.grey[800]!;
  Color get appBarForegroundColor => _isLight() ? Colors.grey[700]! : Colors.white70;
  Color get dropdownColor => cardColor;
  Color get backColorMono => _isLight() ? Colors.white : Colors.black;
  Color get foreColorMono => _isLight() ? Colors.black : Colors.white;

}

lib/theme_mode_number.dart

import 'package:flutter/material.dart';

class ThemeModeNumber {

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

}

lib/version_state.dart

///
/// @author akira ohmachi
/// @copyright ao-system, Inc.
/// @date 2023-01-27
///
library;

class VersionState {

  static String _version = '';

  //バージョンを記録
  static void versionSave(String str) {
    _version = str;
  }
  //バージョンを返す
  static String versionLoad() {
    return _version;
  }

}