pubspec.yaml
name: wheellotterymachine
description: "wheellotterymachine"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2.0.5+31
environment:
sdk: ">=3.3.0 <4.0.0"
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
shared_preferences: ^2.2.2
flutter_tts: ^4.2.3
audioplayers: ^6.5.1
google_mobile_ads: ^6.0.0
flutter_localizations:
sdk: flutter
intl: ^0.20.2 #flutter gen-l10n
dev_dependencies:
flutter_test:
sdk: flutter
flutter_launcher_icons: ^0.14.3 #flutter pub run flutter_launcher_icons
flutter_native_splash: ^2.3.6 #flutter pub run flutter_native_splash:create
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
flutter_icons:
android: "launcher_icon"
ios: true
image_path: "assets/icon/icon.png"
adaptive_icon_background: "assets/icon/icon_back.png"
adaptive_icon_foreground: "assets/icon/icon_fore.png"
flutter_native_splash:
color: '#9da9f5'
image: 'assets/image/splash.png'
color_dark: '#9da9f5'
image_dark: 'assets/image/splash.png'
fullscreen: true
android_12:
icon_background_color: '#9da9f5'
image: 'assets/image/splash.png'
icon_background_color_dark: '#9da9f5'
image_dark: 'assets/image/splash.png'
# The following section is specific to Flutter packages.
flutter:
generate: true
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
assets:
- assets/image/
- assets/sfx/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
lib/ad_banner_widget.dart
import 'package:flutter/cupertino.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:roulettewheeleurope/ad_manager.dart';
class AdBannerWidget extends StatefulWidget {
final AdManager adManager;
const AdBannerWidget({super.key, required this.adManager});
@override
State<AdBannerWidget> createState() => _AdBannerWidgetState();
}
class _AdBannerWidgetState extends State<AdBannerWidget> {
int _lastBannerWidthDp = 0;
bool _isAdLoaded = false;
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final int width = constraints.maxWidth.isFinite ? constraints.maxWidth.truncate() : MediaQuery.of(context).size.width.truncate();
final bannerAd = widget.adManager.bannerAd;
if (width > 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
final bannerAd = widget.adManager.bannerAd;
final bool widthChanged = _lastBannerWidthDp != width;
final bool sizeMismatch = bannerAd == null || bannerAd.size.width != width;
if ((widthChanged || !_isAdLoaded || sizeMismatch) && !_isLoading) {
_lastBannerWidthDp = width;
setState(() { _isAdLoaded = false; _isLoading = true; });
widget.adManager.loadAdaptiveBannerAd(width, () {
if (mounted) {
setState(() { _isAdLoaded = true; _isLoading = false; });
}
});
}
}
});
}
if (_isAdLoaded && bannerAd != null) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: bannerAd.size.width.toDouble(),
height: bannerAd.size.height.toDouble(),
child: AdWidget(ad: bannerAd),
),
],
);
} else {
return const SizedBox.shrink();
}
},
),
);
}
}
lib/ad_manager.dart
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:ui';
import 'package:google_mobile_ads/google_mobile_ads.dart';
class AdManager {
// Test IDs
// static const String _androidAdUnitId = "ca-app-pub-3940256099942544/6300978111";
// static const String _iosAdUnitId = "ca-app-pub-3940256099942544/2934735716";
// Production IDs
static const String _androidAdUnitId = "ca-app-pub-0/0";
static const String _iosAdUnitId = "ca-app-pub-0/0";
static String get _adUnitId => Platform.isIOS ? _iosAdUnitId : _androidAdUnitId;
BannerAd? _bannerAd;
int _lastWidthPx = 0;
VoidCallback? _onLoadedCb;
Timer? _retryTimer;
int _retryAttempt = 0;
BannerAd? get bannerAd => _bannerAd;
Future<void> loadAdaptiveBannerAd(int widthPx, VoidCallback onAdLoaded) async {
_onLoadedCb = onAdLoaded;
_lastWidthPx = widthPx;
_retryAttempt = 0;
_retryTimer?.cancel();
_startLoad(widthPx);
}
Future<void> _startLoad(int widthPx) async {
_bannerAd?.dispose();
AnchoredAdaptiveBannerAdSize? adaptiveSize;
try {
adaptiveSize = await AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(widthPx);
} catch (_) {
adaptiveSize = null;
}
final AdSize size = adaptiveSize ?? AdSize.fullBanner;
_bannerAd = BannerAd(
adUnitId: _adUnitId,
request: const AdRequest(),
size: size,
listener: BannerAdListener(
onAdLoaded: (ad) {
_retryTimer?.cancel();
_retryAttempt = 0;
final cb = _onLoadedCb;
if (cb != null) {
cb();
}
},
onAdFailedToLoad: (ad, err) {
ad.dispose();
_scheduleRetry();
},
),
)..load();
}
void _scheduleRetry() {
_retryTimer?.cancel();
// Exponential backoff: 3s, 6s, 12s, max 30s
_retryAttempt = (_retryAttempt + 1).clamp(1, 5);
final seconds = _retryAttempt >= 4 ? 30 : (3 << (_retryAttempt - 1));
_retryTimer = Timer(Duration(seconds: seconds), () {
_startLoad(_lastWidthPx > 0 ? _lastWidthPx : 320);
});
}
void dispose() {
_bannerAd?.dispose();
_retryTimer?.cancel();
}
}
lib/frame_painter.dart
import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart';
class FramePainter extends CustomPainter {
final ui.Image frame;
FramePainter(this.frame);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
// 1. フレームの短辺を基準に正方形にクロップ
final side = frame.width < frame.height ? frame.width : frame.height;
final src = Rect.fromLTWH(
(frame.width - side) / 2.0,
(frame.height - side) / 2.0,
side.toDouble(),
side.toDouble(),
);
// 2. 描画先も正方形に制限
final length = size.shortestSide;
final dx = (size.width - length) / 2.0;
final dy = (size.height - length) / 2.0;
final dst = Rect.fromLTWH(dx, dy, length, length);
canvas.drawImageRect(frame, src, dst, paint);
}
@override
bool shouldRepaint(covariant FramePainter oldDelegate) {
return oldDelegate.frame != frame;
}
}
lib/main.dart
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'l10n/app_localizations.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wheellotterymachine/ad_manager.dart';
import 'package:wheellotterymachine/ad_banner_widget.dart';
import 'package:wheellotterymachine/models.dart';
import 'package:wheellotterymachine/settings_page.dart';
import 'package:wheellotterymachine/frame_painter.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
MobileAds.instance.initialize();
runApp(const WheelLotteryApp());
}
class WheelLotteryApp extends StatefulWidget {
const WheelLotteryApp({super.key});
@override
State<WheelLotteryApp> createState() => _WheelLotteryAppState();
}
class _WheelLotteryAppState extends State<WheelLotteryApp> {
late Future<AppBootstrap> _bootstrap;
@override
void initState() {
super.initState();
_bootstrap = AppBootstrap.load();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<AppBootstrap>(
future: _bootstrap,
builder: (context, snap) {
if (!snap.hasData) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
home: const Scaffold(body: Center(child: CircularProgressIndicator())),
);
}
final boot = snap.data!;
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Wheel Lottery Machine',
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
locale: _localeFromTag(boot.settings.localeLanguage),
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true),
darkTheme: ThemeData.dark(useMaterial3: true),
themeMode: () {
switch (boot.settings.themeNumber) {
case 2:
return ThemeMode.dark;
case 1:
return ThemeMode.light;
default:
return ThemeMode.system;
}
}(),
home: HomePage(
pref: boot.pref,
settings: boot.settings,
itemStates: boot.items,
onThemeChanged: (n) async {
setState(() {
boot.settings.themeNumber = n;
});
await boot.settings.save(boot.pref);
},
onLocaleChanged: (tag) async {
setState(() {
boot.settings.localeLanguage = tag ?? '';
});
await boot.settings.save(boot.pref);
},
),
);
},
);
}
}
Locale? _localeFromTag(String tag) {
if (tag.isEmpty) return null;
final parts = tag.split('-');
final lang = parts.isNotEmpty ? parts[0] : 'en';
String? script;
String? country;
if (parts.length >= 2) {
final p1 = parts[1];
if (p1.length == 4) {
script = p1;
} else {
country = p1;
}
}
if (parts.length >= 3) {
final p2 = parts[2];
if (p2.length == 4) {
script = p2;
} else {
country = p2;
}
}
return Locale.fromSubtags(languageCode: lang, scriptCode: script, countryCode: country);
}
class HomePage extends StatefulWidget {
final SharedPreferences pref;
final Settings settings;
final List<ItemState> itemStates;
final ValueChanged<int> onThemeChanged;
final ValueChanged<String?> onLocaleChanged;
const HomePage({super.key, required this.pref, required this.settings, required this.itemStates, required this.onThemeChanged, required this.onLocaleChanged});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin {
late List<ui.Image> _decodedFrames = [];
final List<String> _balls = const [
'assets/image/ball_gold.png',
'assets/image/ball_silver.png',
'assets/image/ball_purple.png',
'assets/image/ball_blue.png',
'assets/image/ball_green.png',
'assets/image/ball_yellow.png',
'assets/image/ball_red.png',
'assets/image/ball_white.png',
];
final Map<int, Offset> _pos = const {
95: Offset(440, 540),
96: Offset(457, 558),
97: Offset(474, 589),
98: Offset(492, 630),
99: Offset(508, 620),
100: Offset(527, 614),
101: Offset(547, 616),
102: Offset(567, 627),
103: Offset(586, 651),
104: Offset(601, 647),
105: Offset(617, 650),
106: Offset(634, 665),
107: Offset(646, 670),
108: Offset(657, 680),
109: Offset(669, 709),
110: Offset(680, 710),
};
late AdManager _adManager;
final _audio = AudioPlayer();
final _tts = FlutterTts();
late AnimationController _controller;
bool _busy = false;
int _frame = 0;
int _choice = 0;
String _result = '';
bool _showResult = false;
late List<ItemState> _items;
late Settings _settings;
Offset? _lastBallNormPos; // in 900x900 machine coordinates
bool _isReady = false;
int _readyDotCount = 0;
Timer? _readyTimer;
@override
void initState() {
super.initState();
_adManager = AdManager();
_items = widget.itemStates.map((e) => e.copy()).toList();
_settings = widget.settings;
_setupTts();
_adManager = AdManager();
// Banner is loaded adaptively after layout via _updateBannerForWidth
// Boost image cache capacity to reduce frame evictions
imageCache.maximumSizeBytes = 512 * 1024 * 1024; // 512MB
imageCache.maximumSize = 1000;
_controller = AnimationController(vsync: this);
_controller.addListener(() {
final progress = _controller.value;
final rawIdx = (progress * 120).floor().clamp(0, 119);
final step = _frameStep();
final dispCount = (120 / step).round();
final dispIdxN = (progress * dispCount).floor().clamp(0, dispCount - 1);
final dispIdx = (dispIdxN * step).clamp(0, 119);
var speakNow = false;
setState(() {
_frame = dispIdx;
if (!_showResult && rawIdx >= 110) {
_showResult = true;
speakNow = true;
}
});
if (speakNow && _settings.speechNumber == 1 && _result.isNotEmpty) {
_speakResult();
}
});
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
setState(() {
_busy = false;
_frame = 0;
});
}
});
// ローディング中に点をアニメーション
_readyTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
if (!_isReady) {
setState(() {
_readyDotCount = (_readyDotCount + 1) % 4; // 0~3 を繰り返す
});
}
});
WidgetsBinding.instance.addPostFrameCallback((_) async {
_decodedFrames = await loadAllFrames();
setState(() {
_isReady = true;
});
});
}
@override
void dispose() {
_readyTimer?.cancel();
_controller.dispose();
_adManager.dispose();
_audio.dispose();
super.dispose();
}
Future<List<ui.Image>> loadAllFrames() async {
final List<ui.Image> decodedFrames = [];
for (int i = 0; i < 120; i++) {
final path = 'assets/image/machine${(i + 1).toString().padLeft(3, '0')}.webp';
final data = await rootBundle.load(path);
final codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
final frame = await codec.getNextFrame();
decodedFrames.add(frame.image);
}
return decodedFrames;
}
Future<void> _speakResult() async {
try {
await _tts.setVolume(_settings.volumeSpeech.clamp(0, 10) / 10);
await _tts.speak(_result);
} catch (_) {}
}
Future<void> _setupTts() async {
await _tts.setSpeechRate(0.5);
await _tts.setVolume(_settings.volumeSpeech.clamp(0, 10) / 10);
if (_settings.speechVoice.isNotEmpty) {
try {
if (_settings.speechLocale.isNotEmpty) {
await _tts.setVoice({'name': _settings.speechVoice, 'locale': _settings.speechLocale});
} else {
await _tts.setVoice({'name': _settings.speechVoice});
}
} catch (_) {}
}
}
@override
Widget build(BuildContext context) {
if (!_isReady) {
final dots = "." * _readyDotCount;
return Scaffold(
backgroundColor: const Color(0xFF4CAF50),
body: Center(
child: Text(
"$dots loading $dots",
style: const TextStyle(fontSize: 24, color: Colors.white),
),
),
);
}
return Scaffold(
backgroundColor: Colors.green,
body: SafeArea(
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Row(children: [
const Spacer(),
Padding(
padding: const EdgeInsets.only(right: 10),
child: IconButton(
onPressed: _busy ? null : _onSetting,
tooltip: AppLocalizations.of(context).setting,
icon: Icon(Icons.settings, color: Colors.white.withValues(alpha: 0.85)),
),
),
]),
const SizedBox(height: 5),
Expanded(child: LayoutBuilder(builder: (context, c) {
final box = min(c.maxWidth, c.maxHeight);
final ml = (c.maxWidth - box) / 2;
final mt = (c.maxHeight - box) / 2;
final bs = box / 25;
final op = (_frame >= 95 || _showResult) ? 1.0 : 0.0;
Offset? p = _pos[_frame];
if (p == null && _frame >= 95) {
final keys = _pos.keys.where((k) => k <= _frame).toList()..sort();
if (keys.isNotEmpty) p = _pos[keys.last];
}
if (p != null) {
_lastBallNormPos = p;
}
// When animation finished, keep showing last position
final useP = p ?? (_showResult ? _lastBallNormPos : null);
double? x, y;
if (useP != null) {
final px = box / 900.0;
final h = bs / 2;
x = ml + px * useP.dx - h;
y = mt + px * useP.dy - h;
}
final idx = _frame.clamp(0, 119).toInt();
final dpr = MediaQuery.of(context).devicePixelRatio;
final targetWidthPx = max(1, (box * dpr).round());
return Stack(children: [
_frameImage(idx,targetWidthPx),
if (x != null && y != null)
Positioned(
left: x,
top: y,
width: bs,
height: bs,
child: Opacity(
opacity: op,
child: Image.asset(
_balls[_choice],
cacheWidth: max(1, (bs * dpr).round()),
filterQuality: FilterQuality.low,
),
),
),
// Result text at bottom inside the machine area
Positioned(
left: ml,
top: mt,
width: box,
height: box,
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _showResult ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 0),
child: Text(
_result,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
),
),
),
),
),
),
),
]);
})),
const SizedBox(height: 12),
Center(
child: Opacity(
opacity: _busy ? 0.4 : 1.0,
child: ElevatedButton(
onPressed: _busy ? null : _onStart,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
fixedSize: const Size(130, 130),
backgroundColor: Colors.white.withValues(alpha: 0.25),
foregroundColor: Colors.white,
elevation: 0,
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
AppLocalizations.of(context).drawLot,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white,fontSize: 48),
),
),
),
),
),
),
]),
),
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 20),
AdBannerWidget(adManager: _adManager),
],
),
);
}
Widget _frameImage(int idx, int targetWidthPx) {
return Center(
child: SizedBox(
width: targetWidthPx.toDouble(),
height: targetWidthPx.toDouble(),
child: CustomPaint(
painter: FramePainter(_decodedFrames[idx]),
),
),
);
}
Future<void> _onStart() async {
if (_busy) {
return;
}
setState(() {
_busy = true;
_showResult = false;
_frame = 0;
_lastBallNormPos = null;
});
final sum = _items.fold<int>(0, (p, e) => p + e.qty);
if (sum == 0) {
setState(() {
_result = AppLocalizations.of(context).empty;
_showResult = true;
_busy = false;
});
return;
}
final rnd = Random();
var remain = rnd.nextInt(sum);
var choice = 0;
for (int i = 0; i < _items.length; i++) {
final q = _items[i].qty;
if (q == 0) {
continue;
}
if (remain >= q) {
remain -= q;
} else {
choice = i;
_items[i].qty = max(0, _items[i].qty - 1);
break;
}
}
_choice = choice;
_result = _items[_choice].name;
await AppBootstrap.saveItemStates(widget.pref, _items);
final vol = _settings.volumeSpin.clamp(0, 10) / 10;
if (vol > 0) {
try {
await _audio.setSource(AssetSource('sfx/garagara2.wav'));
await _audio.setVolume(vol);
await _audio.setPlaybackRate((_settings.shortNumber + 1).toDouble());
unawaited(_audio.resume());
} catch (_) {}
}
final msPerFrame = _settings.animationSpeed.clamp(1, 1000);
_controller
..duration = Duration(milliseconds: msPerFrame * _displayedFrameCount())
..reset();
_controller.forward();
}
int _frameStep() {
switch (_settings.shortNumber.clamp(0, 4)) {
case 1:
return 2; // 60 frames
case 2:
return 3; // 40 frames
case 3:
return 4; // 30 frames
case 4:
return 6; // 20 frames
default:
return 1; // 120 frames
}
}
int _displayedFrameCount() {
final step = _frameStep();
return (120 / step).round();
}
Future<void> _onSetting() async {
if (_busy) {
return;
}
if (_settings.pin.isNotEmpty) {
final ok = await _promptPin();
if (!ok) return;
}
final res = await Navigator.of(context).push<SettingsResult>(MaterialPageRoute(
builder: (_) => SettingsPage(pref: widget.pref, items: _items.map((e) => e.copy()).toList(), settings: _settings.clone())
));
if (res != null) {
setState(() {
_items = res.items;
_settings = res.settings;
});
await AppBootstrap.saveItemStates(widget.pref, _items);
await _settings.save(widget.pref);
await _setupTts();
// notify app to rebuild with new theme/locale
widget.onThemeChanged(_settings.themeNumber);
widget.onLocaleChanged(_settings.localeLanguage.isEmpty ? null : _settings.localeLanguage);
}
}
Future<bool> _promptPin() async {
final c = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('PIN'),
content: TextField(controller: c, keyboardType: TextInputType.number, obscureText: true, decoration: const InputDecoration(hintText: 'Enter PIN')),
actions: [TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('OK'))],
),
);
if (ok != true) {
return false;
}
return c.text == _settings.pin;
}
}
lib/models.dart
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
class ConstValue {
static const String settings = 'settings';
static const String pin = 'pin';
static const String animationSpeed = 'animationSpeed';
static const String speechNumber = 'speechNumber';
static const String speechVoice = 'speechVoice';
static const String speechLocale = 'speechLocale';
static const String shortNumber = 'shortNumber';
static const String volumeSpin = 'volumeSpin';
static const String volumeSpeech = 'volumeSpeech';
static const String themeNumber = 'themeNumber';
static const String localeLanguage = 'localeLanguage';
static const String itemName1 = 'itemName1';
static const String itemName2 = 'itemName2';
static const String itemName3 = 'itemName3';
static const String itemName4 = 'itemName4';
static const String itemName5 = 'itemName5';
static const String itemName6 = 'itemName6';
static const String itemName7 = 'itemName7';
static const String itemName8 = 'itemName8';
static const String itemQty1 = 'itemQty1';
static const String itemQty2 = 'itemQty2';
static const String itemQty3 = 'itemQty3';
static const String itemQty4 = 'itemQty4';
static const String itemQty5 = 'itemQty5';
static const String itemQty6 = 'itemQty6';
static const String itemQty7 = 'itemQty7';
static const String itemQty8 = 'itemQty8';
}
class ItemState {
String name;
int qty;
ItemState({required this.name, required this.qty});
ItemState copy() => ItemState(name: name, qty: qty);
}
class Settings {
String pin;
int animationSpeed; // 1..1000 ms per frame
int speechNumber; // 0/1
String speechVoice;
String speechLocale;
int shortNumber; // 0..4
int volumeSpin; // 0..10
int volumeSpeech; // 0..10
int themeNumber; // 0 system, 1 light, 2 dark
String localeLanguage; // e.g. 'ja', '' for system
Settings({
this.pin = '',
this.animationSpeed = 25,
this.speechNumber = 1,
this.speechVoice = '',
this.speechLocale = '',
this.shortNumber = 0,
this.volumeSpin = 10,
this.volumeSpeech = 10,
this.themeNumber = 0,
this.localeLanguage = '',
});
Settings clone() => Settings(
pin: pin,
animationSpeed: animationSpeed,
speechNumber: speechNumber,
speechVoice: speechVoice,
speechLocale: speechLocale,
shortNumber: shortNumber,
volumeSpin: volumeSpin,
volumeSpeech: volumeSpeech,
themeNumber: themeNumber,
localeLanguage: localeLanguage,
);
Future<void> save(SharedPreferences pref) async {
await pref.setString(ConstValue.pin, pin);
await pref.setInt(ConstValue.animationSpeed, animationSpeed);
await pref.setInt(ConstValue.speechNumber, speechNumber);
await pref.setString(ConstValue.speechVoice, speechVoice);
await pref.setString(ConstValue.speechLocale, speechLocale);
await pref.setInt(ConstValue.shortNumber, shortNumber);
await pref.setInt(ConstValue.volumeSpin, volumeSpin);
await pref.setInt(ConstValue.volumeSpeech, volumeSpeech);
await pref.setInt(ConstValue.themeNumber, themeNumber);
await pref.setString(ConstValue.localeLanguage, localeLanguage);
}
static Settings fromPrefs(SharedPreferences pref) {
final s = Settings();
s.pin = pref.getString(ConstValue.pin) ?? '';
s.animationSpeed = (pref.getInt(ConstValue.animationSpeed) ?? 25).clamp(1, 1000);
s.speechNumber = pref.getInt(ConstValue.speechNumber) ?? 1;
s.speechVoice = pref.getString(ConstValue.speechVoice) ?? '';
s.speechLocale = pref.getString(ConstValue.speechLocale) ?? '';
s.shortNumber = pref.getInt(ConstValue.shortNumber) ?? 0;
s.volumeSpin = pref.getInt(ConstValue.volumeSpin) ?? 10;
s.volumeSpeech = pref.getInt(ConstValue.volumeSpeech) ?? 10;
s.themeNumber = (pref.getInt(ConstValue.themeNumber) ?? 0).clamp(0, 2);
s.localeLanguage = pref.getString(ConstValue.localeLanguage) ?? '';
return s;
}
}
class AppBootstrap {
final SharedPreferences pref;
final Settings settings;
final List<ItemState> items;
AppBootstrap(this.pref, this.settings, this.items);
static Future<AppBootstrap> load() async {
final pref = await SharedPreferences.getInstance();
final settings = Settings.fromPrefs(pref);
final items = loadItemStates(pref);
return AppBootstrap(pref, settings, items);
}
static List<ItemState> loadItemStates(SharedPreferences pref) {
final items = [
ItemState(name: pref.getString(ConstValue.itemName1) ?? '', qty: pref.getInt(ConstValue.itemQty1) ?? 0),
ItemState(name: pref.getString(ConstValue.itemName2) ?? '', qty: pref.getInt(ConstValue.itemQty2) ?? 0),
ItemState(name: pref.getString(ConstValue.itemName3) ?? '', qty: pref.getInt(ConstValue.itemQty3) ?? 0),
ItemState(name: pref.getString(ConstValue.itemName4) ?? '', qty: pref.getInt(ConstValue.itemQty4) ?? 0),
ItemState(name: pref.getString(ConstValue.itemName5) ?? '', qty: pref.getInt(ConstValue.itemQty5) ?? 0),
ItemState(name: pref.getString(ConstValue.itemName6) ?? '', qty: pref.getInt(ConstValue.itemQty6) ?? 0),
ItemState(name: pref.getString(ConstValue.itemName7) ?? '', qty: pref.getInt(ConstValue.itemQty7) ?? 0),
ItemState(name: pref.getString(ConstValue.itemName8) ?? '', qty: pref.getInt(ConstValue.itemQty8) ?? 0),
];
if (items.every((e) => e.name.isEmpty)) {
items[0]
..name = 'Grand Prize'
..qty = 1;
items[1]
..name = 'Second Prize'
..qty = 1;
items[2]
..name = 'Premium Prize'
..qty = 2;
items[3]
..name = 'Prize A'
..qty = 2;
items[4]
..name = 'Prize B'
..qty = 3;
items[5]
..name = 'Prize C'
..qty = 10;
items[6]
..name = 'Consolation Prize'
..qty = 100;
items[7]
..name = 'Thank You Prize'
..qty = 500;
saveItemStates(pref, items);
}
return items;
}
static Future<void> saveItemStates(SharedPreferences pref, List<ItemState> items) async {
await pref.setString(ConstValue.itemName1, items[0].name);
await pref.setInt(ConstValue.itemQty1, items[0].qty);
await pref.setString(ConstValue.itemName2, items[1].name);
await pref.setInt(ConstValue.itemQty2, items[1].qty);
await pref.setString(ConstValue.itemName3, items[2].name);
await pref.setInt(ConstValue.itemQty3, items[2].qty);
await pref.setString(ConstValue.itemName4, items[3].name);
await pref.setInt(ConstValue.itemQty4, items[3].qty);
await pref.setString(ConstValue.itemName5, items[4].name);
await pref.setInt(ConstValue.itemQty5, items[4].qty);
await pref.setString(ConstValue.itemName6, items[5].name);
await pref.setInt(ConstValue.itemQty6, items[5].qty);
await pref.setString(ConstValue.itemName7, items[6].name);
await pref.setInt(ConstValue.itemQty7, items[6].qty);
await pref.setString(ConstValue.itemName8, items[7].name);
await pref.setInt(ConstValue.itemQty8, items[7].qty);
}
static int weightedChoice(List<ItemState> items, Random rng) {
final sum = items.fold<int>(0, (p, e) => p + e.qty);
if (sum == 0) return 0;
var remain = rng.nextInt(sum);
for (int i = 0; i < items.length; i++) {
final q = max(0, items[i].qty);
if (q == 0) continue;
if (remain >= q) {
remain -= q;
} else {
return i;
}
}
return 0;
}
}
lib/settings_page.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'l10n/app_localizations.dart';
import 'package:wheellotterymachine/models.dart';
import 'package:wheellotterymachine/ad_manager.dart';
import 'package:wheellotterymachine/ad_banner_widget.dart';
class SettingsResult {
final List<ItemState> items;
final Settings settings;
SettingsResult(this.items, this.settings);
}
class SettingsPage extends StatefulWidget {
final SharedPreferences pref;
final List<ItemState> items;
final Settings settings;
const SettingsPage({super.key, required this.pref, required this.items, required this.settings});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _pinCtrl;
late final TextEditingController _speedCtrl;
late final List<TextEditingController> _nameCtrls;
late final List<TextEditingController> _qtyCtrls;
late int _shortNumber;
late int _volumeSpin;
late int _volumeSpeech;
late bool _speechEnabled;
late int _themeNumber; // 0 system, 1 light, 2 dark
late String _speechVoice;
late String _speechLocale;
List<_VoiceOption> _voices = [];
late AdManager _adManager;
String? _selectedLocaleTag;
final List<String> _ballAssets = const [
'assets/image/ball_gold.png',
'assets/image/ball_silver.png',
'assets/image/ball_purple.png',
'assets/image/ball_blue.png',
'assets/image/ball_green.png',
'assets/image/ball_yellow.png',
'assets/image/ball_red.png',
'assets/image/ball_white.png',
];
final List<String> _ballLabels = const ['GOLD', 'SILVER', 'PURPLE', 'BLUE', 'GREEN', 'YELLOW', 'RED', 'WHITE'];
@override
void initState() {
super.initState();
_adManager = AdManager();
_pinCtrl = TextEditingController(text: widget.settings.pin);
_speedCtrl = TextEditingController(text: widget.settings.animationSpeed.toString());
_nameCtrls = List.generate(8, (i) => TextEditingController(text: widget.items[i].name));
_qtyCtrls = List.generate(8, (i) => TextEditingController(text: widget.items[i].qty == 0 ? '' : widget.items[i].qty.toString()));
_shortNumber = widget.settings.shortNumber;
_volumeSpin = widget.settings.volumeSpin;
_volumeSpeech = widget.settings.volumeSpeech;
_speechEnabled = widget.settings.speechNumber == 1;
_themeNumber = widget.settings.themeNumber;
_speechVoice = widget.settings.speechVoice;
_speechLocale = widget.settings.speechLocale;
_selectedLocaleTag = widget.settings.localeLanguage.isEmpty ? null : widget.settings.localeLanguage;
_loadVoices();
}
Future<void> _loadVoices() async {
final tts = FlutterTts();
try {
final vs = await tts.getVoices;
final List<_VoiceOption> voiceOptions = [];
if (vs is List) {
for (final v in vs) {
if (v is Map && v['name'] is String && v['locale'] is String) {
voiceOptions.add(_VoiceOption(v['locale'] as String, v['name'] as String));
}
}
}
voiceOptions.sort((a, b) => a.label.compareTo(b.label));
setState(() => _voices = voiceOptions);
} catch (_) {}
}
@override
void dispose() {
_pinCtrl.dispose();
_speedCtrl.dispose();
for (final c in _nameCtrls) {
c.dispose();
}
for (final c in _qtyCtrls) {
c.dispose();
}
_adManager.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.close),
tooltip: l.close,
onPressed: () => Navigator.of(context).pop(),
),
title: null,
actions: [
Padding(
padding: const EdgeInsets.only(right: 10),
child: IconButton(
onPressed: _apply,
tooltip: l.apply,
icon: const Icon(Icons.check),
),
),
],
),
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SafeArea(
child: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 50),
children: [
_buildItemsTable(),
const Divider(height: 40, thickness: 1),
_buildPin(),
const Divider(height: 40, thickness: 1),
_buildSpeed(),
const SizedBox(height: 10),
_buildShort(),
const Divider(height: 40, thickness: 1),
_buildVolumes(),
const Divider(height: 40, thickness: 1),
_buildSpeech(),
const Divider(height: 40, thickness: 1),
_buildLanguage(),
const Divider(height: 40, thickness: 1),
_buildTheme(),
const Divider(height: 40, thickness: 1),
],
),
),
],
),
),
),
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 10),
AdBannerWidget(adManager: _adManager),
],
),
);
}
Widget _buildItemsTable() {
final l = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.resultsAndQuantity),
const SizedBox(height: 8),
Table(
columnWidths: const {
0: FixedColumnWidth(24),
1: IntrinsicColumnWidth(),
2: FlexColumnWidth(1),
3: FixedColumnWidth(64),
},
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
children: List.generate(8, (i) {
return TableRow(children: [
Padding(padding: const EdgeInsets.all(4), child: Image.asset(_ballAssets[i], width: 20, height: 20)),
Padding(padding: const EdgeInsets.all(4), child: Text(_ballLabels[i])),
Padding(
padding: const EdgeInsets.all(4),
child: TextField(
controller: _nameCtrls[i],
decoration: InputDecoration(isDense: true, hintText: 'Name'),
inputFormatters: [LengthLimitingTextInputFormatter(50)],
),
),
Padding(
padding: const EdgeInsets.all(4),
child: SizedBox(
width: 60,
child: TextField(
controller: _qtyCtrls[i],
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(isDense: true),
),
),
),
]);
}),
),
],
);
}
Widget _buildPin() {
final l = AppLocalizations.of(context);
return Column(crossAxisAlignment: CrossAxisAlignment.start,children: [
Row(
children: [
Text(l.pin),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _pinCtrl,
obscureText: true,
decoration: const InputDecoration(isDense: true),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
],
),
const SizedBox(height: 6),
Text(l.pinNote, style: const TextStyle(fontSize: 12, color: Colors.grey)),
]);
}
Widget _buildSpeed() {
final l = AppLocalizations.of(context);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(
children: [
Text(l.animationSpeed),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _speedCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(isDense: true),
),
),
],
),
]);
}
Widget _buildShort() {
final l = AppLocalizations.of(context);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Text(l.speedUp),
Expanded(
child: Slider(
min: 0,
max: 4,
divisions: 4,
value: _shortNumber.toDouble(),
label: _shortNumber.toString(),
onChanged: (v) => setState(() => _shortNumber = v.round()),
),
),
Text(
_shortNumber.toInt().toString().padLeft(2, '0'),
style: Theme.of(context).textTheme.titleMedium,
),
]),
]);
}
Widget _buildVolumes() {
final l = AppLocalizations.of(context);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Expanded(child: Text(l.volumeSpin)),
SizedBox(
width: 220,
child: Slider(
min: 0,
max: 10,
divisions: 10,
value: _volumeSpin.toDouble(),
label: _volumeSpin.toString(),
onChanged: (v) => setState(() => _volumeSpin = v.round()),
),
),
Text(
_volumeSpin.toInt().toString().padLeft(2, '0'),
style: Theme.of(context).textTheme.titleMedium,
),
]),
Row(children: [
Expanded(child: Text(l.volumeSpeech)),
SizedBox(
width: 220,
child: Slider(
min: 0,
max: 10,
divisions: 10,
value: _volumeSpeech.toDouble(),
label: _volumeSpeech.toString(),
onChanged: (v) => setState(() => _volumeSpeech = v.round()),
),
),
Text(
_volumeSpeech.toInt().toString().padLeft(2, '0'),
style: Theme.of(context).textTheme.titleMedium,
),
]),
]);
}
Widget _buildSpeech() {
final l = AppLocalizations.of(context);
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
SwitchListTile(
value: _speechEnabled,
onChanged: (v) => setState(() => _speechEnabled = v),
title: Text(l.readOutResults),
contentPadding: const EdgeInsets.symmetric(horizontal: 0),
),
if (_voices.isNotEmpty)
DropdownButtonFormField<String>(
initialValue: () {
String? sel;
for (final o in _voices) {
if (o.name == _speechVoice && o.locale == _speechLocale) {
sel = o.id;
break;
}
}
sel ??= _voices.first.id;
return sel;
}(),
items: _voices.map((o) => DropdownMenuItem<String>(value: o.id, child: Text(o.label))).toList(),
onChanged: (v) {
if (v == null) return;
final idx = v.indexOf('|');
if (idx > 0) {
setState(() {
_speechLocale = v.substring(0, idx);
_speechVoice = v.substring(idx + 1);
});
}
},
decoration: InputDecoration(labelText: l.voice),
),
]);
}
Widget _buildLanguage() {
final l = AppLocalizations.of(context);
const languageOptions = {
'en': 'English',
'bg': 'Български',
'cs': 'Čeština',
'da': 'Dansk',
'de': 'Deutsch',
'el': 'Ελληνικά',
'es': 'Español (España)',
'es-419': 'Español (Latinoamérica)',
'et': 'Eesti',
'fi': 'Suomi',
'fr': 'Français',
'hu': 'Magyar',
'id': 'Indonesia',
'it': 'Italiano',
'ja': '日本語',
'ko': '한국어',
'lt': 'Lietuvių',
'lv': 'Latviešu',
'nl': 'Nederlands',
'no': 'Norsk',
'pl': 'Polski',
'pt': 'Português',
'pt-BR': 'Português (Brasil)',
'pt-PT': 'Português (Portugal)',
'ro': 'Română',
'ru': 'Русский',
'sk': 'Slovenčina',
'sv': 'Svenska',
'th': 'ไทย',
'tr': 'Türkçe',
'uk': 'Українська',
'vi': 'Tiếng Việt',
'zh': '中文',
'zh-Hans': '简体中文',
'zh-Hant': '繁體中文',
};
return Column(crossAxisAlignment: CrossAxisAlignment.start,children: [
ListTile(
title: Text(l.language),
contentPadding: const EdgeInsets.symmetric(horizontal: 0),
trailing: DropdownButton<String?>(
value: _selectedLocaleTag,
hint: Text(l.systemDefault),
items: [
DropdownMenuItem<String?>(value: null, child: Text(l.systemDefault)),
...languageOptions.entries.map((e) => DropdownMenuItem<String?>(value: e.key, child: Text(e.value))),
],
onChanged: (value) {
setState(() {
_selectedLocaleTag = value;
});
},
),
),
]);
}
Widget _buildTheme() {
final l = AppLocalizations.of(context);
return Column(crossAxisAlignment: CrossAxisAlignment.start,children: [
ListTile(
title: Text(l.theme),
contentPadding: const EdgeInsets.symmetric(horizontal: 0),
trailing: DropdownButton<int>(
value: _themeNumber,
items: [
DropdownMenuItem(value: 0, child: Text(l.systemDefault)),
DropdownMenuItem(value: 1, child: Text(l.lightTheme)),
DropdownMenuItem(value: 2, child: Text(l.darkTheme)),
],
onChanged: (v) => setState(() => _themeNumber = v ?? 0),
),
),
]);
}
void _apply() {
final items = List<ItemState>.generate(8, (i) {
final name = _nameCtrls[i].text.trim();
final qty = int.tryParse(_qtyCtrls[i].text.trim().isEmpty ? '0' : _qtyCtrls[i].text.trim()) ?? 0;
return ItemState(name: name, qty: qty);
});
int speed = int.tryParse(_speedCtrl.text.trim().isEmpty ? '10' : _speedCtrl.text.trim()) ?? 10;
speed = speed.clamp(1, 1000);
final settings = Settings(
pin: _pinCtrl.text.trim(),
animationSpeed: speed,
speechNumber: _speechEnabled ? 1 : 0,
speechVoice: _speechVoice,
speechLocale: _speechLocale,
shortNumber: _shortNumber,
volumeSpin: _volumeSpin,
volumeSpeech: _volumeSpeech,
themeNumber: _themeNumber,
localeLanguage: _selectedLocaleTag ?? '',
);
Navigator.of(context).pop(SettingsResult(items, settings));
}
}
class _VoiceOption {
final String locale;
final String name;
const _VoiceOption(this.locale, this.name);
String get id => '$locale|$name';
String get label => '$locale $name';
}