pubspec.yaml
name: roulettewheeleurope
description: "Roulette Wheel Europe"
# 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+17
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
flutter_localizations:
sdk: flutter
intl: ^0.20.2 #flutter gen-l10n
shared_preferences: ^2.5.3
flutter_tts: ^4.2.3
google_mobile_ads: ^6.0.0
audioplayers: ^6.0.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
flutter_launcher_icons: ^0.14.4 #flutter pub run flutter_launcher_icons
flutter_native_splash: ^2.4.0 #flutter pub run flutter_native_splash:create
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
flutter_icons:
android: "launcher_icon"
ios: true
image_path: "assets/icon/icon.png"
adaptive_icon_background: "assets/icon/icon_back.png"
adaptive_icon_foreground: "assets/icon/icon_fore.png"
flutter_native_splash:
color: '#9da9f5'
image: 'assets/image/splash.png'
color_dark: '#9da9f5'
image_dark: 'assets/image/splash.png'
fullscreen: true
android_12:
icon_background_color: '#9da9f5'
image: 'assets/image/splash.png'
icon_background_color_dark: '#9da9f5'
image_dark: 'assets/image/splash.png'
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
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/icon/
- assets/image/
- assets/sound/
# 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/color_utils.dart
import 'package:flutter/material.dart';
// Maps roulette result codes to Colors.
// Codes: 'g' (green), 'k' (black), 'r' (red)
Color colorFromCode(String code) {
switch (code) {
case 'g':
return const Color(0xFF00BB00);
case 'k':
return const Color(0xFF222222);
case 'r':
return const Color(0xFFD00000);
default:
return Colors.transparent;
}
}
lib/const_value.dart
class ConstValue {
static const String settings = "settings";
static const String speechNumber = "speechNumber"; // 0 or 1
static const String shortNumber = "shortNumber"; // 0..9
static const String themeNumber = "themeNumber"; // 0 light, 1 dark
static const String localeLanguage = "localeLanguage"; // "en", "ja", ... or ""
static const String countdownNumber = "countdownNumber"; // 0 off, 1 on
static const String soundVolume = "soundVolume"; // 0..10
static const String speechVolume = "speechVolume"; // 0..10
static const String voiceId = "voiceId"; // "<locale>|<name>"
}
lib/main.dart
import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'l10n/app_localizations.dart';
import 'package:roulettewheeleurope/roulette_home.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await MobileAds.instance.initialize();
runApp(const RouletteWheelApp());
}
class RouletteWheelApp extends StatefulWidget {
const RouletteWheelApp({super.key});
@override
State<RouletteWheelApp> createState() => _RouletteWheelAppState();
}
class _RouletteWheelAppState extends State<RouletteWheelApp> {
ThemeMode _themeMode = ThemeMode.light;
Locale? _locale;
void updateThemeAndLocale({required int themeNumber, required String localeLanguage}) {
setState(() {
// 0: system, 1: light, 2: dark
switch (themeNumber) {
case 2:
_themeMode = ThemeMode.dark;
break;
case 1:
_themeMode = ThemeMode.light;
break;
default:
_themeMode = ThemeMode.system;
}
_locale = _localeFromTag(localeLanguage);
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Roulette Wheel',
themeMode: _themeMode,
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true),
darkTheme: ThemeData.dark(useMaterial3: true),
locale: _locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: RouletteHome(onUpdateApp: updateThemeAndLocale),
);
}
}
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);
}
lib/roulette_home.dart
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:audioplayers/audioplayers.dart';
import 'l10n/app_localizations.dart';
import 'package:roulettewheeleurope/const_value.dart';
import 'package:roulettewheeleurope/setting_page.dart';
import 'package:roulettewheeleurope/wheel_view.dart';
import 'package:roulettewheeleurope/ad_manager.dart';
import 'package:roulettewheeleurope/ad_banner_widget.dart';
import 'package:roulettewheeleurope/color_utils.dart';
class RouletteHome extends StatefulWidget {
final void Function({required int themeNumber, required String localeLanguage}) onUpdateApp;
const RouletteHome({super.key, required this.onUpdateApp});
@override
State<RouletteHome> createState() => _RouletteHomeState();
}
class _RouletteHomeState extends State<RouletteHome> {
late AdManager _adManager;
// UI
bool _startUiVisible = true;
bool _settingUiVisible = true;
// UI mirror state for Flutter-native wheel
double _uiWheelAngle = 0;
double _uiBallLeft = 0;
double _uiBallTop = 0;
double _uiBallSize = 0;
bool _uiBallVisible = false;
double _uiAlphaThree = 0;
double _uiAlphaTwo = 0;
double _uiAlphaOne = 0;
double _uiAlphaNoMoreBets = 0;
double _uiAlphaResult = 0;
String _uiResultText = '';
String _uiResultColor = '';
final List<_HistoryItem> _history = <_HistoryItem>[];
// TTS
final FlutterTts _tts = FlutterTts();
List<Map> _voices = [];
// SFX
final AudioPlayer _audio = AudioPlayer();
// Prefs / settings
int speechNumber = 1;
int shortNumber = 0; // 0..9
int themeNumber = 0; // 0 light, 1 dark
String localeLanguage = ""; // empty = system
int countdownNumber = 1; // 1 on, 0 off
int speechVolume = 10; // 0..10
int soundVolume = 10; // 0..10
String voiceId = ""; // "<locale>|<name>"
// Wheel logic
double baseSize = 0; // logical size of square wheel area (in px)
double wheelAngle = 360;
double ballAngle = 0;
double wheelAngleStart = 0;
bool ballRotateFlag = false;
int ballTick = 0;
double adjustAngle = 0;
double ballDistanceRatio = _ballDistanceStart;
bool busy = false;
Timer? _timer;
static const double _ballSizeRatio = 0.04; // 4% of wheel diameter
static const double _ballDistanceStart = 0.89;
static const double _ballDistanceEnd = 0.535;
static const List<_RouletteSlot> _slots = [
_RouletteSlot("0", "g"),
_RouletteSlot("32", "r"),
_RouletteSlot("15", "k"),
_RouletteSlot("19", "r"),
_RouletteSlot("4", "k"),
_RouletteSlot("21", "r"),
_RouletteSlot("2", "k"),
_RouletteSlot("25", "r"),
_RouletteSlot("17", "k"),
_RouletteSlot("34", "r"),
_RouletteSlot("6", "k"),
_RouletteSlot("27", "r"),
_RouletteSlot("13", "k"),
_RouletteSlot("36", "r"),
_RouletteSlot("11", "k"),
_RouletteSlot("30", "r"),
_RouletteSlot("8", "k"),
_RouletteSlot("23", "r"),
_RouletteSlot("10", "k"),
_RouletteSlot("5", "r"),
_RouletteSlot("24", "k"),
_RouletteSlot("16", "r"),
_RouletteSlot("33", "k"),
_RouletteSlot("1", "r"),
_RouletteSlot("20", "k"),
_RouletteSlot("14", "r"),
_RouletteSlot("31", "k"),
_RouletteSlot("9", "r"),
_RouletteSlot("22", "k"),
_RouletteSlot("18", "r"),
_RouletteSlot("29", "k"),
_RouletteSlot("7", "r"),
_RouletteSlot("28", "k"),
_RouletteSlot("12", "r"),
_RouletteSlot("35", "k"),
_RouletteSlot("3", "r"),
_RouletteSlot("26", "k"),
];
@override
void initState() {
super.initState();
_adManager = AdManager();
_loadPrefs();
_initTts();
_initAudio();
_startTicker();
}
@override
void dispose() {
_adManager.dispose();
_audio.dispose();
_timer?.cancel();
super.dispose();
}
Future<void> _initAudio() async {
await _audio.setReleaseMode(ReleaseMode.stop);
await _audio.setVolume((soundVolume.clamp(0, 10)) / 10.0);
// Preload source once to avoid per-play asset resolution/IO
try {
await _audio.setSource(AssetSource('sound/kachi.wav')); //assets/は不要
} catch (_) {}
}
Future<void> _initTts() async {
try {
final v = await _tts.getVoices; // returns List<dynamic>
if (v is List) {
_voices = v.cast<Map>();
setState(() {});
}
} catch (_) {}
unawaited(_setSpeechVoiceFromId());
try {
await _tts.setVolume((speechVolume.clamp(0, 10)) / 10.0);
} catch (_) {}
}
Future<void> _loadPrefs() async {
final pref = await SharedPreferences.getInstance();
speechNumber = pref.getInt(ConstValue.speechNumber) ?? 1;
voiceId = pref.getString(ConstValue.voiceId) ?? '';
shortNumber = pref.getInt(ConstValue.shortNumber) ?? 0;
themeNumber = pref.getInt(ConstValue.themeNumber) ?? 0;
localeLanguage = pref.getString(ConstValue.localeLanguage) ?? "";
countdownNumber = pref.getInt(ConstValue.countdownNumber) ?? 1;
speechVolume = pref.getInt(ConstValue.speechVolume) ?? 10;
soundVolume = pref.getInt(ConstValue.soundVolume) ?? 10;
widget.onUpdateApp(themeNumber: themeNumber, localeLanguage: localeLanguage);
setState(() {});
try {
await _tts.setVolume((speechVolume.clamp(0, 10)) / 10.0);
await _audio.setVolume((soundVolume.clamp(0, 10)) / 10.0);
} catch (_) {}
}
Future<void> _saveSpeechNumber() async {
final pref = await SharedPreferences.getInstance();
await pref.setInt(ConstValue.speechNumber, speechNumber);
}
Future<void> _saveVoiceId() async {
final pref = await SharedPreferences.getInstance();
await pref.setString(ConstValue.voiceId, voiceId);
}
Future<void> _saveShortNumber() async {
final pref = await SharedPreferences.getInstance();
await pref.setInt(ConstValue.shortNumber, shortNumber);
}
Future<void> _saveThemeNumber() async {
final pref = await SharedPreferences.getInstance();
await pref.setInt(ConstValue.themeNumber, themeNumber);
}
Future<void> _saveLocaleLanguage() async {
final pref = await SharedPreferences.getInstance();
await pref.setString(ConstValue.localeLanguage, localeLanguage);
}
Future<void> _saveCountdownNumber() async {
final pref = await SharedPreferences.getInstance();
await pref.setInt(ConstValue.countdownNumber, countdownNumber);
}
Future<void> _saveSoundVolume() async {
final pref = await SharedPreferences.getInstance();
await pref.setInt(ConstValue.soundVolume, soundVolume);
}
Future<void> _saveSpeechVolume() async {
final pref = await SharedPreferences.getInstance();
await pref.setInt(ConstValue.speechVolume, speechVolume);
}
Future<void> _setSpeechVoiceFromId() async {
if (_voices.isEmpty || voiceId.isEmpty) return;
final idx = voiceId.indexOf('|');
String selLocale = '';
String selName = voiceId;
if (idx >= 0) {
selLocale = voiceId.substring(0, idx);
selName = voiceId.substring(idx + 1);
}
Map? match;
if (selLocale.isNotEmpty) {
match = _voices.cast<Map?>().firstWhere(
(e) => (e?['name']?.toString() ?? '') == selName && (e?['locale']?.toString() ?? '') == selLocale,
orElse: () => null,
);
}
match ??= _voices.cast<Map?>().firstWhere(
(e) => (e?['name']?.toString() ?? '') == selName,
orElse: () => null,
);
if (match != null) {
final locale = (match['locale']?.toString() ?? selLocale);
final name = (match['name']?.toString() ?? selName);
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 (_) {}
}
}
void _startTicker() {
_timer = Timer.periodic(const Duration(milliseconds: 25), (_) {
// wheel rotation
wheelAngle -= 0.5;
if (wheelAngle < 0) {
wheelAngle = 359.5;
}
_setWheelRotation(wheelAngle);
if (ballRotateFlag) {
adjustAngle += 0.5;
ballAngle += 5;
if (ballAngle >= 360) {
ballAngle = 0;
}
if (ballTick > 0) {
ballTick -= 1;
if (ballTick == 500) {
if (countdownNumber == 1) {
_showOverlay(three: 0.8);
if (speechNumber == 1) {
_speak("3");
}
}
}
if (ballTick == 450) {
if (countdownNumber == 1) {
_showOverlay(three: 0.0, two: 0.8);
if (speechNumber == 1) {
_speak("2");
}
}
}
if (ballTick == 400) {
if (countdownNumber == 1) {
_showOverlay(two: 0.0, one: 0.8);
if (speechNumber == 1) {
_speak("1");
}
}
}
if (ballTick == 350) {
_showOverlay(one: 0.0, noMoreBets: 0.8);
_speak(_localizedNoMoreBets());
}
if (ballTick == 250) {
_showOverlay(noMoreBets: 0.0);
ballTick -= (math.Random().nextDouble() * 100).toInt();
}
if (ballTick < 5) {
ballDistanceRatio = (_ballDistanceStart + _ballDistanceEnd) / 2;
}
if (ballTick < 1) {
ballDistanceRatio = _ballDistanceEnd;
}
if (ballTick <= 0) {
ballRotateFlag = false;
_setStartUiVisible(true);
_resultNumber();
busy = false;
_playPocketSound();
Future.delayed(const Duration(milliseconds: 800), () {
_speak(_speakTextForNumber(_uiResultText));
});
}
}
}
_ballPosition();
});
}
String _localizedNoMoreBets() => AppLocalizations.of(context)?.noMoreBets ?? 'no more bets';
void _speak(String text) {
if (speechNumber == 1) {
_tts.speak(text);
}
}
void _onStart() {
if (busy) {
return;
}
busy = true;
_setStartUiVisible(false);
_showOverlay(resultAlpha: 0.0);
ballAngle = 0;
_setBallVisible(true);
ballDistanceRatio = _ballDistanceStart;
wheelAngleStart = wheelAngle;
ballTick = (10 - shortNumber) * 100 + 260; // 1260..360
adjustAngle = 0;
ballRotateFlag = true;
}
void _resultNumber() {
double angle = ballAngle;
angle = ((angle - wheelAngleStart + adjustAngle) / (360 / 37)).toInt() * (360 / 37) + (180 / 37);
angle = 180 - angle + 0.5;
angle += 3600;
angle %= 360;
int num = 37 - (angle / (360 / 37)).toInt();
num %= 37;
final slot = _slots[num];
final resultNumber = slot.number;
final resultColor = slot.color;
_setResult(number: resultNumber, color: resultColor);
_showOverlay(resultAlpha: 1.0);
_addHistory(resultNumber, resultColor);
}
String _speakTextForNumber(String number) {
if (number == '00') {
return 'double zero';
}
return number;
}
void _addHistory(String number, String color) {
setState(() {
_history.insert(0, _HistoryItem(number, color));
if (_history.length > 20) {
_history.removeLast();
}
});
}
Future<void> _playPocketSound() async {
final vol = soundVolume.clamp(0, 10) / 10.0;
if (vol <= 0) return;
try {
await _audio.setVolume(vol);
// Restart from beginning without reloading the asset
await _audio.seek(Duration.zero);
unawaited(_audio.resume());
} catch (_) {
// Fallback: try a direct play if preloading failed
try {
await _audio.stop();
await _audio.play(AssetSource('sound/kachi.wav')); //assets/は不要
} catch (_) {}
}
}
Widget _buildHistoryList() {
if (_history.isEmpty) {
return SizedBox.shrink();
}
return ListView.separated(
reverse: false,
itemCount: _history.length,
separatorBuilder: (_, __) => const SizedBox(height: 1),
itemBuilder: (context, index) {
final item = _history[index];
return Container(
height: 19,
decoration: BoxDecoration(
color: colorFromCode(item.color),
borderRadius: BorderRadius.circular(20),
),
alignment: Alignment.center,
child: Text(
item.number,
style: const TextStyle(color: Colors.white, fontSize: 16),
),
);
},
);
}
void _ballPosition() {
if (baseSize <= 0) {
return;
}
final ballSize = baseSize * _ballSizeRatio;
_setBallSize(ballSize.toInt());
double angle = ballAngle;
if (!ballRotateFlag) {
angle = ((angle - wheelAngleStart + adjustAngle) / (360 / 37)).toInt() * (360 / 37) + (180 / 37);
angle += wheelAngle;
}
double x = (-math.sin(angle * (math.pi / 180)) * (baseSize / 2));
double y = (math.cos(angle * (math.pi / 180)) * (baseSize / 2));
x *= ballDistanceRatio;
y *= ballDistanceRatio;
x += baseSize / 2.0 * 0.89;
y += baseSize / 2.0 * 0.89;
x += ballSize * 0.9;
y += ballSize * 0.9;
_setBallPosition(x.toInt(), y.toInt());
}
// Channel helpers
Future<void> _setWheelRotation(double angle) async {
setState(() {
_uiWheelAngle = angle;
});
}
Future<void> _setBallPosition(int x, int y) async {
setState(() {
_uiBallLeft = x.toDouble();
_uiBallTop = y.toDouble();
});
}
Future<void> _setBallSize(int sizePx) async {
setState(() {
_uiBallSize = sizePx.toDouble();
});
}
Future<void> _setBallVisible(bool visible) async {
setState(() {
_uiBallVisible = visible;
});
}
Future<void> _showOverlay({double? three, double? two, double? one, double? noMoreBets, double? resultAlpha}) async {
setState(() {
if (three != null) { _uiAlphaThree = three; }
if (two != null) { _uiAlphaTwo = two; }
if (one != null) { _uiAlphaOne = one; }
if (noMoreBets != null) { _uiAlphaNoMoreBets = noMoreBets; }
if (resultAlpha != null) { _uiAlphaResult = resultAlpha; }
});
}
Future<void> _setResult({required String number, required String color}) async {
setState(() {
_uiResultText = number;
_uiResultColor = color;
});
}
void _setStartUiVisible(bool on) {
setState(() {
_startUiVisible = on;
_settingUiVisible = on;
});
}
@override
Widget build(BuildContext context) {
final bgColor = (themeNumber == 2) ? Color.fromARGB(255, 20,40,30) : Color.fromARGB(255, 50,130,60);
return Scaffold(
backgroundColor: bgColor,
body: SafeArea(
child: Column(
children: [
Row(children: [
const Spacer(),
Padding(
padding: const EdgeInsets.only(right: 10),
child: IconButton(
onPressed: _settingUiVisible ? _onTapSetting : null,
tooltip: AppLocalizations.of(context)!.setting,
icon: Icon(Icons.settings, color: Colors.white.withValues(alpha: _settingUiVisible ? 0.85 : 0)),
),
),
]),
const SizedBox(height: 5),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final paddingH = 10.0;
final width = constraints.maxWidth - paddingH * 2;
baseSize = width;
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
child: Column(
children: [
AspectRatio(
aspectRatio: 1,
child: Center(
child: WheelFlutterView(
size: width,
wheelAngleDeg: _uiWheelAngle,
ballVisible: _uiBallVisible,
ballLeft: _uiBallLeft,
ballTop: _uiBallTop,
ballSize: _uiBallSize,
alphaThree: _uiAlphaThree,
alphaTwo: _uiAlphaTwo,
alphaOne: _uiAlphaOne,
alphaNoMoreBets: _uiAlphaNoMoreBets,
alphaResult: _uiAlphaResult,
resultText: _uiResultText,
resultColor: _uiResultColor,
),
),
),
const SizedBox(height: 20),
Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: 60,
height: 200,
child: _buildHistoryList(),
),
),
Align(
alignment: Alignment.center,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: _startUiVisible ? 1.0 : 0.0,
child: Opacity(
opacity: busy ? 0.4 : 1.0,
child: ElevatedButton(
onPressed: busy ? null : _onStart,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
fixedSize: const Size(160, 160),
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)!.start,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 28),
),
),
),
),
),
),
)
],
),
const SizedBox(height: 200),
],
),
),
);
},
),
),
],
),
),
bottomNavigationBar: AdBannerWidget(adManager: _adManager),
);
}
Future<void> _onTapSetting() async {
if (busy) {
return;
}
final result = await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SettingPage(
speechNumber: speechNumber,
shortNumber: shortNumber,
themeNumber: themeNumber,
localeLanguage: localeLanguage,
countdownNumber: countdownNumber,
speechVolume: speechVolume,
soundVolume: soundVolume,
voiceId: voiceId,
),
),
);
if (result is Map) {
final lastSpeechNumber = speechNumber;
speechNumber = (result[ConstValue.speechNumber] as int?) ?? 1;
if (lastSpeechNumber != speechNumber) {
await _saveSpeechNumber();
}
final lastVoiceId = voiceId;
voiceId = (result[ConstValue.voiceId] as String?) ?? voiceId;
if (lastVoiceId != voiceId) {
await _saveVoiceId();
await _setSpeechVoiceFromId();
}
final lastShortNumber = shortNumber;
shortNumber = (result[ConstValue.shortNumber] as int?) ?? shortNumber;
if (lastShortNumber != shortNumber) {
await _saveShortNumber();
}
final lastTheme = themeNumber;
themeNumber = (result[ConstValue.themeNumber] as int?) ?? themeNumber;
if (lastTheme != themeNumber) {
await _saveThemeNumber();
}
final lastLocale = localeLanguage;
localeLanguage = (result[ConstValue.localeLanguage] as String?) ?? localeLanguage;
if (lastLocale != localeLanguage) {
await _saveLocaleLanguage();
}
final lastCountdown = countdownNumber;
countdownNumber = (result[ConstValue.countdownNumber] as int?) ?? countdownNumber;
if (lastCountdown != countdownNumber) {
await _saveCountdownNumber();
}
final lastSoundVolume = soundVolume;
soundVolume = (result[ConstValue.soundVolume] as int?) ?? soundVolume;
if (lastSoundVolume != soundVolume) {
await _saveSoundVolume();
try {
await _audio.setVolume((soundVolume.clamp(0, 10)) / 10.0);
} catch (_) {}
}
final lastSpeechVolume = speechVolume;
speechVolume = (result[ConstValue.speechVolume] as int?) ?? speechVolume;
if (lastSpeechVolume != speechVolume) {
await _saveSpeechVolume();
try {
await _tts.setVolume((speechVolume.clamp(0, 10)) / 10.0);
} catch (_) {}
}
widget.onUpdateApp(themeNumber: themeNumber, localeLanguage: localeLanguage);
setState(() {});
}
}
}
class _HistoryItem {
final String number;
final String color;
_HistoryItem(this.number, this.color);
}
class _RouletteSlot {
final String number;
final String color;
const _RouletteSlot(this.number, this.color);
}
lib/setting_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'l10n/app_localizations.dart';
import 'package:roulettewheeleurope/const_value.dart';
import 'package:roulettewheeleurope/ad_manager.dart';
import 'package:roulettewheeleurope/ad_banner_widget.dart';
class SettingPage extends StatefulWidget {
final int speechNumber;
final int shortNumber; // 0..9
final int themeNumber; // 0 system, 1 light, 2 dark
final String localeLanguage; // BCP-47 tag or ""
final int countdownNumber; // 0 off, 1 on
final int speechVolume; // 0..10
final int soundVolume; // 0..10
final String voiceId; // "<locale>|<name>"
const SettingPage({
super.key,
required this.speechNumber,
required this.shortNumber,
required this.themeNumber,
required this.localeLanguage,
required this.countdownNumber,
required this.speechVolume,
required this.soundVolume,
required this.voiceId,
});
@override
State<SettingPage> createState() => _SettingPageState();
}
class _SettingPageState extends State<SettingPage> {
late AdManager _adManager;
late bool _speechOn;
late int _shortNumber;
late int _themeNumber; // 0 system, 1 light, 2 dark
late String _languageCode; // BCP-47 tag or empty for system
late bool _countdownOn;
late int _speechVolume; // 0..10
late int _soundVolume; // 0..10
String _voiceId = '';
List<_VoiceOption> _voices = [];
final Map<String, String> _languageOptions = const {
'en': 'English',
'bg': 'Bulgarian',
'cs': 'Čeština',
'da': 'Dansk',
'de': 'Deutsch',
'el': 'Ελληνικά',
'es': 'Español',
'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',
'ro': 'Română',
'ru': 'Русский',
'sk': 'Slovenčina',
'sv': 'Svenska',
'th': 'ไทย',
'tr': 'Türkçe',
'uk': 'Українська',
'vi': 'Tiếng Việt',
'zh': '中文',
};
@override
void initState() {
super.initState();
_adManager = AdManager();
_speechOn = widget.speechNumber != 0;
_shortNumber = widget.shortNumber;
_themeNumber = {0, 1, 2}.contains(widget.themeNumber) ? widget.themeNumber : 0;
_languageCode = widget.localeLanguage;
_countdownOn = (widget.countdownNumber != 0);
_speechVolume = widget.speechVolume;
_soundVolume = widget.soundVolume;
_voiceId = widget.voiceId;
_loadVoices();
}
@override
void dispose() {
_adManager.dispose();
super.dispose();
}
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;
// Ensure voiceId is valid; fallback to first
if (_voices.isNotEmpty) {
final exists = _voices.any((o) => o.id == _voiceId);
if (!exists) {
_voiceId = _voices.first.id;
}
}
});
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.close),
tooltip: l.cancel,
onPressed: () => Navigator.of(context).pop(),
),
title: null,
actions: [
Padding(
padding: const EdgeInsets.only(right: 10),
child: IconButton(
onPressed: _onApply,
tooltip: l.apply,
icon: const Icon(Icons.check),
),
),
],
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 10),
_buildShortNumber(),
const Divider(height: 40, thickness: 1),
_buildCountdownSwitch(),
const Divider(height: 40, thickness: 1),
_buildSpeechSwitch(),
_buildSpeechVolume(),
_buildSpeechLanguage(),
const Divider(height: 40, thickness: 1),
_buildSoundVolume(),
const Divider(height: 40, thickness: 1),
_buildLanguage(),
const Divider(height: 40, thickness: 1),
_buildTheme(),
const Divider(height: 40, thickness: 1),
const SizedBox(height: 120),
],
),
),
),
],
),
),
bottomNavigationBar: AdBannerWidget(adManager: _adManager),
);
}
void _onApply() {
Navigator.of(context).pop({
ConstValue.speechNumber: _speechOn ? 1 : 0,
ConstValue.voiceId: _voiceId,
ConstValue.shortNumber: _shortNumber,
ConstValue.themeNumber: _themeNumber,
ConstValue.localeLanguage: _languageCode,
ConstValue.countdownNumber: _countdownOn ? 1 : 0,
ConstValue.speechVolume: _speechVolume,
ConstValue.soundVolume: _soundVolume,
});
}
Widget _buildShortNumber() {
final l = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(children: [
Expanded(child: Text(l.shortNumber,style: Theme.of(context).textTheme.bodyLarge)),
SizedBox(
child: Slider(
min: 0,
max: 10,
divisions: 10,
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 _buildCountdownSwitch() {
final l = AppLocalizations.of(context)!;
return SwitchListTile(
value: _countdownOn,
onChanged: (v) => setState(() => _countdownOn = v),
title: Text(l.countdown),
);
}
Widget _buildSpeechSwitch() {
final l = AppLocalizations.of(context)!;
return SwitchListTile(
value: _speechOn,
onChanged: (v) => setState(() => _speechOn = v),
title: Text(l.speechNumber),
);
}
Widget _buildSpeechVolume() {
final l = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(children: [
Expanded(child: Text(l.speechVolume, style: Theme.of(context).textTheme.bodyLarge)),
SizedBox(
child: Slider(
min: 0,
max: 10,
divisions: 10,
value: _speechVolume.toDouble(),
label: _speechVolume.toString(),
onChanged: (v) => setState(() => _speechVolume = v.round()),
),
),
Text(
_speechVolume.toInt().toString().padLeft(2, '0'),
style: Theme.of(context).textTheme.titleMedium,
)
])
);
}
Widget _buildSpeechLanguage() {
if (_voices.isEmpty) {
return SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(left: 16,right: 8),
child: DropdownButtonFormField<String>(
initialValue: () {
if (_voiceId.isNotEmpty && _voices.any((o) => o.id == _voiceId)) {
return _voiceId;
}
return _voices.first.id;
}(),
items: _voices
.map((o) => DropdownMenuItem<String>(value: o.id, child: Text(o.label)))
.toList(),
onChanged: (v) {
if (v == null) return;
setState(() => _voiceId = v);
},
),
);
}
Widget _buildSoundVolume() {
final l = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(children: [
Expanded(child: Text(l.soundVolume, style: Theme.of(context).textTheme.bodyLarge)),
SizedBox(
child: Slider(
min: 0,
max: 10,
divisions: 10,
value: _soundVolume.toDouble(),
label: _soundVolume.toString(),
onChanged: (v) => setState(() => _soundVolume = v.round()),
),
),
Text(
_soundVolume.toInt().toString().padLeft(2, '0'),
style: Theme.of(context).textTheme.titleMedium)
])
);
}
Widget _buildLanguage() {
final l = AppLocalizations.of(context)!;
return ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 10),
title: Text(l.language),
trailing: DropdownButton<String?>(
value: _languageCode.isEmpty ? null : _languageCode,
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(() => _languageCode = value ?? ''),
),
);
}
Widget _buildTheme() {
final l = AppLocalizations.of(context)!;
return ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 10),
title: Text(l.theme),
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),
),
);
}
}
class _VoiceOption {
final String locale;
final String name;
const _VoiceOption(this.locale, this.name);
String get id => '$locale|$name';
String get label => '$locale $name';
}
lib/wheel_view.dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:roulettewheeleurope/color_utils.dart';
class WheelFlutterView extends StatelessWidget {
final double size;
final double wheelAngleDeg;
final bool ballVisible;
final double ballLeft;
final double ballTop;
final double ballSize;
final double alphaThree;
final double alphaTwo;
final double alphaOne;
final double alphaNoMoreBets;
final double alphaResult;
final String resultText;
final String resultColor; // 'g'|'k'|'r'
const WheelFlutterView({
super.key,
required this.size,
required this.wheelAngleDeg,
required this.ballVisible,
required this.ballLeft,
required this.ballTop,
required this.ballSize,
required this.alphaThree,
required this.alphaTwo,
required this.alphaOne,
required this.alphaNoMoreBets,
required this.alphaResult,
required this.resultText,
required this.resultColor,
});
@override
Widget build(BuildContext context) {
final wheelAngleRad = wheelAngleDeg * (math.pi / 180.0);
return SizedBox(
width: size,
height: size,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
// Base
Positioned.fill(
child: Image.asset(
'assets/image/roulettewheel_base.png',
fit: BoxFit.contain,
filterQuality: FilterQuality.low,
),
),
// Top (rotating)
Positioned.fill(
child: Transform.rotate(
angle: wheelAngleRad,
child: Image.asset(
'assets/image/roulettewheel_top.png',
fit: BoxFit.contain,
filterQuality: FilterQuality.low,
),
),
),
// Ball
Positioned(
left: ballLeft,
top: ballTop,
child: Opacity(
opacity: ballVisible ? 1.0 : 0.0,
child: Image.asset(
'assets/image/ball.png',
width: ballSize,
height: ballSize,
fit: BoxFit.contain,
filterQuality: FilterQuality.low,
),
),
),
// Overlays 3,2,1, No more bets (full-size)
Positioned.fill(
child: IgnorePointer(
ignoring: true,
child: AnimatedOpacity(
opacity: alphaThree.clamp(0.0, 1.0),
duration: const Duration(milliseconds: 300),
child: Image.asset('assets/image/three.png', fit: BoxFit.contain),
),
),
),
Positioned.fill(
child: IgnorePointer(
ignoring: true,
child: AnimatedOpacity(
opacity: alphaTwo.clamp(0.0, 1.0),
duration: const Duration(milliseconds: 300),
child: Image.asset('assets/image/two.png', fit: BoxFit.contain),
),
),
),
Positioned.fill(
child: IgnorePointer(
ignoring: true,
child: AnimatedOpacity(
opacity: alphaOne.clamp(0.0, 1.0),
duration: const Duration(milliseconds: 300),
child: Image.asset('assets/image/one.png', fit: BoxFit.contain),
),
),
),
Positioned.fill(
child: IgnorePointer(
ignoring: true,
child: AnimatedOpacity(
opacity: alphaNoMoreBets.clamp(0.0, 1.0),
duration: const Duration(milliseconds: 300),
child: Image.asset('assets/image/nomorebets.png', fit: BoxFit.contain),
),
),
),
// Result text
Positioned.fill(
child: IgnorePointer(
ignoring: true,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: alphaResult.clamp(0.0, 1.0),
child: Align(
alignment: Alignment.topLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 3),
decoration: BoxDecoration(
color: colorFromCode(resultColor),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.white, width: 1),
),
child: Text(
resultText,
style: const TextStyle(
color: Colors.white,
fontSize: 48,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
),
],
),
);
}
}