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;
}
}