pubspec.yaml
name: numberroulette
description: "numberroulette"
# 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.2.6+33
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.5.2
flutter_localizations:
sdk: flutter
intl: ^0.20.2 #flutter gen-l10n
google_mobile_ads: ^6.0.0
flutter_tts: ^4.0.2
equatable: ^2.0.7
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: ^5.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'
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/
# 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_manager.dart
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
class AdManager {
// テストID
// static const String _androidAdUnitId = "ca-app-pub-3940256099942544/6300978111";
// static const String _iosAdUnitId = "ca-app-pub-3940256099942544/2934735716";
// 本番ID
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;
bool _isBannerAdLoaded = false;
int _lastWidthPx = 0;
VoidCallback? _onLoadedCb;
Timer? _retryTimer;
int _retryAttempt = 0;
bool get isBannerAdLoaded => _isBannerAdLoaded;
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();
_isBannerAdLoaded = false;
AnchoredAdaptiveBannerAdSize? adaptiveSize;
try {
adaptiveSize = await AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(widthPx);
} catch (_) {
adaptiveSize = null;
}
final AdSize size = adaptiveSize ?? AdSize.fullBanner; // prefer larger fallback
_bannerAd = BannerAd(
adUnitId: _adUnitId,
request: const AdRequest(),
size: size,
listener: BannerAdListener(
onAdLoaded: (ad) {
_retryTimer?.cancel();
_retryAttempt = 0;
_isBannerAdLoaded = true;
final cb = _onLoadedCb;
if (cb != null) cb();
},
onAdFailedToLoad: (ad, err) {
ad.dispose();
// Retry with backoff to mitigate transient no-fill/network issues
_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/main.dart
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:numberroulette/models.dart';
import 'package:numberroulette/ad_manager.dart';
import 'package:numberroulette/setting_screen.dart';
import 'package:numberroulette/l10n/gen/app_localizations.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
MobileAds.instance.initialize();
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
ThemeMode _themeMode = ThemeMode.light;
Locale? _locale;
@override
void initState() {
super.initState();
_loadThemeAndLocale();
}
Future<void> _loadThemeAndLocale() async {
final prefs = await SharedPreferences.getInstance();
final themeNumber = prefs.getInt('themeNumber') ?? 0;
final localeLanguage = prefs.getString('localeLanguage') ?? '';
setState(() {
_themeMode = ThemeMode.values[min(themeNumber, ThemeMode.values.length - 1)];
_locale = localeLanguage.isNotEmpty ? _localeFromTag(localeLanguage) : null;
});
}
Future<void> _setTheme(int themeNumber) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('themeNumber', themeNumber);
setState(() {
_themeMode = ThemeMode.values[min(themeNumber, ThemeMode.values.length - 1)];
});
}
Future<void> _setLocale(String? localeTag) async {
final prefs = await SharedPreferences.getInstance();
if (localeTag != null && localeTag.isNotEmpty) {
await prefs.setString('localeLanguage', localeTag);
setState(() => _locale = _localeFromTag(localeTag));
} else {
await prefs.remove('localeLanguage');
setState(() => _locale = null);
}
}
Locale _localeFromTag(String tag) {
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);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Number Roulette',
themeMode: _themeMode,
theme: ThemeData(
primarySwatch: Colors.blueGrey,
brightness: Brightness.light,
scaffoldBackgroundColor: Colors.white,
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF9eabfa),
foregroundColor: Colors.white,
),
textTheme: const TextTheme(
bodyLarge: TextStyle(color: Colors.black),
bodyMedium: TextStyle(color: Colors.black),
bodySmall: TextStyle(color: Colors.black),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(34),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
),
),
darkTheme: ThemeData(
primarySwatch: Colors.blueGrey,
brightness: Brightness.dark,
scaffoldBackgroundColor: Colors.black,
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xff333333),
foregroundColor: Colors.white,
),
textTheme: const TextTheme(
bodyLarge: TextStyle(color: Colors.white),
bodyMedium: TextStyle(color: Colors.white),
bodySmall: TextStyle(color: Colors.white),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(34),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
),
),
),
locale: _locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: MyHomePage(
setTheme: _setTheme,
setLocale: _setLocale,
),
);
}
}
class MyHomePage extends StatefulWidget {
final Function(int) setTheme;
final Function(String?) setLocale;
const MyHomePage({super.key, required this.setTheme, required this.setLocale});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
late SharedPreferences _prefs;
final FlutterTts flutterTts = FlutterTts();
late NumberSettings _settings;
bool _isLoading = true;
late AnimationController _controller;
late Animation<double> _animation;
int? _currentNumber;
String? _resultText;
Color? _currentBackgroundColor;
final _random = Random();
List<Map<String, String>> _availableTtsVoices = [];
late AdManager _adManager;
bool _isAdLoaded = false;
int? _lastBannerWidthDp;
// Colors (from NumberRoulette original)
final List<Color> _colorLight = const [
Color(0xFFEF9A9A), Color(0xFFF48FB1), Color(0xFFCE93D8), Color(0xFFB39DDB),
Color(0xFF9FA8DA), Color(0xFF90CAF9), Color(0xFF81D4FA), Color(0xFF80DEEA),
Color(0xFF80CBC4), Color(0xFFA5D6A7), Color(0xFFC5E1A5), Color(0xFFE6EE9C),
Color(0xFFFFF590), Color(0xFFFFE082), Color(0xFFFFCC80), Color(0xFFFFAB91),
Color(0xFFE57373), Color(0xFFF06292), Color(0xFFBA68C8), Color(0xFF9575CD),
Color(0xFF7986CB), Color(0xFF64B5F6), Color(0xFF4FC3F7), Color(0xFF4DD0E1),
Color(0xFF4DB6AC), Color(0xFF81C784), Color(0xFFAED581), Color(0xFFDCE775),
Color(0xFFFFF176), Color(0xFFFFD54F), Color(0xFFFFB74D), Color(0xFFFF8A65),
];
final List<Color> _colorDark = const [
Color(0xFFEF5350), Color(0xFFEC407A), Color(0xFFAB47BC), Color(0xFF7E57C2),
Color(0xFF5C6BC0), Color(0xFF42A5F5), Color(0xFF29B6FC), Color(0xFF26C6DA),
Color(0xFF26A69A), Color(0xFF66BB6A), Color(0xFF9CCC65), Color(0xFFD4E157),
Color(0xFFFFEE58), Color(0xFFFFCA28), Color(0xFFFFA726), Color(0xFFFF7043),
Color(0xFFF44336), Color(0xFFE91E63), Color(0xFF9C27B0), Color(0xFF673AB7),
Color(0xFF3F51B5), Color(0xFF2196F3), Color(0xFF03A9F4), Color(0xFF00BCD4),
Color(0xFF009688), Color(0xFF4CAF50), Color(0xFF8BC34A), Color(0xFFCDDC39),
Color(0xFFFFEB3B), Color(0xFFFFC107), Color(0xFFFF9800), Color(0xFFFF5722),
];
final Color _fixedBgColor = const Color(0xFFaaaaaa);
List<int> _orderedNumbers = [];
@override
void initState() {
super.initState();
_adManager = AdManager();
_initAsync();
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 10))
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_determineWinner();
}
});
_animation = Tween<double>(begin: 0, end: 360 * 20).animate(CurvedAnimation(
parent: _controller,
curve: Curves.linear,
));
}
Future<void> _initAsync() async {
_prefs = await SharedPreferences.getInstance();
_settings = _loadSettings();
_rebuildOrderedNumbers();
await _initTts();
_updateVisualForAngle(0);
setState(() => _isLoading = false);
}
void _updateBannerForWidth(int widthDp) {
if (widthDp <= 0) return;
if (_lastBannerWidthDp == widthDp && _isAdLoaded && _adManager.bannerAd != null &&
_adManager.bannerAd!.size.width == widthDp) {
return;
}
_lastBannerWidthDp = widthDp;
_adManager.loadAdaptiveBannerAd(widthDp, () {
if (mounted) setState(() => _isAdLoaded = true);
});
}
NumberSettings _loadSettings() {
return NumberSettings(
minNumber: _prefs.getInt('minNumber') ?? 0,
maxNumber: _prefs.getInt('maxNumber') ?? 5,
positionReverse: (_prefs.getInt('positionReverse') ?? 0) == 1,
positionRandom: (_prefs.getInt('positionRandom') ?? 0) == 1,
fixBackground: (_prefs.getInt('fixBackground') ?? 0) == 1,
shortRotation: (_prefs.getInt('shortRotation') ?? 0) == 1,
speechNumber: (_prefs.getInt('speechNumber') ?? 1) == 1,
speechVoice: _prefs.getString('speechVoice') ?? '',
speechLocale: _prefs.getString('speechLocale') ?? '',
maxSpeedDuration: _prefs.getDouble('maxSpeedDuration') ?? 5.0,
themeNumber: _prefs.getInt('themeNumber') ?? 0,
localeLanguage: _prefs.getString('localeLanguage') ?? '',
resultTextScale: _prefs.getDouble('resultTextScale') ?? 1.0,
rouletteTextScale: _prefs.getDouble('rouletteTextScale') ?? 1.0,
);
}
Future<void> _saveSettings() async {
await _prefs.setInt('minNumber', _settings.minNumber);
await _prefs.setInt('maxNumber', _settings.maxNumber);
await _prefs.setInt('positionReverse', _settings.positionReverse ? 1 : 0);
await _prefs.setInt('positionRandom', _settings.positionRandom ? 1 : 0);
await _prefs.setInt('fixBackground', _settings.fixBackground ? 1 : 0);
await _prefs.setInt('shortRotation', _settings.shortRotation ? 1 : 0);
await _prefs.setInt('speechNumber', _settings.speechNumber ? 1 : 0);
await _prefs.setString('speechVoice', _settings.speechVoice);
await _prefs.setString('speechLocale', _settings.speechLocale);
await _prefs.setDouble('maxSpeedDuration', _settings.maxSpeedDuration);
await _prefs.setInt('themeNumber', _settings.themeNumber);
await _prefs.setString('localeLanguage', _settings.localeLanguage);
await _prefs.setDouble('resultTextScale', _settings.resultTextScale);
await _prefs.setDouble('rouletteTextScale', _settings.rouletteTextScale);
}
void _rebuildOrderedNumbers() {
final minN = _settings.minNumber;
final maxN = _settings.maxNumber;
var list = List<int>.generate(maxN - minN + 1, (i) => minN + i);
if (_settings.positionRandom) {
list.shuffle(_random);
} else if (_settings.positionReverse) {
list = list.reversed.toList();
}
_orderedNumbers = list;
}
Future<void> _initTts() async {
_availableTtsVoices = (await flutterTts.getVoices as List<dynamic>)
.map((e) => {'name': e['name'].toString(), 'locale': e['locale'].toString().replaceAll('_', '-')})
.toList();
Map<String, String>? selectedVoice;
if (_settings.speechVoice.isNotEmpty && _settings.speechLocale.isNotEmpty) {
final normalizedSettingsLocale = _settings.speechLocale.replaceAll('_', '-');
try {
selectedVoice = _availableTtsVoices.firstWhere(
(voice) => voice['name'] == _settings.speechVoice && voice['locale'] == normalizedSettingsLocale,
);
} catch (_) {}
}
if (selectedVoice != null) {
await flutterTts.setVoice(selectedVoice);
} else {
await flutterTts.setLanguage('en-US');
}
await flutterTts.setSpeechRate(0.5);
await flutterTts.setVolume(1.0);
await flutterTts.setPitch(1.0);
}
@override
void dispose() {
_controller.dispose();
flutterTts.stop();
_adManager.dispose();
super.dispose();
}
void _onClickStart() {
setState(() {
_resultText = null;
});
final double scale = _settings.shortRotation ? 0.1 : 1.0;
final double easeInDuration = 1.0 * scale; // seconds
final double linearDuration = _settings.maxSpeedDuration * scale; // seconds
final double easeOutDuration = 8.0 * scale; // seconds
final double totalDuration = easeInDuration + linearDuration + easeOutDuration;
_controller.duration = Duration(milliseconds: (totalDuration * 1000).round());
const double baseEaseIn = 1.0;
const double baseLinearDuration = 5.0;
const double baseEaseOut = 8.0;
const double baseTotalDuration = baseEaseIn + baseLinearDuration + baseEaseOut;
const double baseRotationAmount = 360 * 28;
final double targetRotationAmount = baseRotationAmount * (totalDuration / baseTotalDuration);
final double randomExtraRotation = 360 * (_random.nextDouble() - 0.5);
final double beginAngle = _animation.value;
final double endAngle = beginAngle + targetRotationAmount + randomExtraRotation;
_animation = Tween<double>(begin: beginAngle, end: endAngle).animate(CurvedAnimation(
parent: _controller,
curve: ThreePhaseRouletteCurve(
easeInDuration: easeInDuration,
linearDuration: linearDuration,
easeOutDuration: easeOutDuration,
),
));
_controller.forward(from: 0.0);
}
void _updateCurrentNumber() {
if (_orderedNumbers.isEmpty) return;
final double currentAngle = _animation.value;
// Map to 0..360 and pointer at top (270 degrees in our draw coordinates)
final double effectiveAngle = (360 - (currentAngle % 360) + 270) % 360;
final int count = _orderedNumbers.length;
final double sweep = 360.0 / count;
int index = (effectiveAngle / sweep).floor();
if (index < 0 || index >= count) index = index % count;
final number = _orderedNumbers[index];
_currentNumber = number;
final colorIdx = index % _colorLight.length;
final Color segColor = _colorLight[colorIdx];
if (_controller.isAnimating && _settings.fixBackground) {
_currentBackgroundColor = _fixedBgColor;
} else {
_currentBackgroundColor = segColor;
}
}
void _determineWinner() {
_updateCurrentNumber();
setState(() {
_resultText = _currentNumber?.toString();
if (_settings.speechNumber && _resultText != null) {
flutterTts.speak(_resultText!);
}
});
}
void _updateVisualForAngle(double angle) {
if (_orderedNumbers.isEmpty) return;
final double effectiveAngle = (360 - (angle % 360) + 270) % 360;
final int count = _orderedNumbers.length;
final double sweep = 360.0 / count;
int index = (effectiveAngle / sweep).floor();
if (index < 0 || index >= count) index = index % count;
final number = _orderedNumbers[index];
setState(() {
_currentNumber = number;
final Color segColor = _colorLight[index % _colorLight.length];
_currentBackgroundColor = segColor;
_resultText ??= number.toString();
});
}
Future<void> _onClickSetting() async {
final updated = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SettingScreen(initialSettings: _settings),
),
);
if (updated != null && mounted) {
setState(() => _settings = updated);
await _saveSettings();
widget.setTheme(_settings.themeNumber);
widget.setLocale(_settings.localeLanguage.isEmpty ? null : _settings.localeLanguage);
_rebuildOrderedNumbers();
await _initTts();
_updateVisualForAngle(_animation.value);
}
}
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context);
if (_isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
_updateCurrentNumber();
return Scaffold(
backgroundColor: _currentBackgroundColor,
appBar: AppBar(
title: const Text(''),
elevation: 0,
actions: [
IconButton(icon: const Icon(Icons.settings), onPressed: _onClickSetting),
const SizedBox(width: 24),
],
),
body: SafeArea(
child: Stack(
children: [
Column(
children: [
LinearProgressIndicator(
value: _controller.value,
minHeight: 5.0,
backgroundColor: Colors.white.withValues(alpha: 0.3),
valueColor: AlwaysStoppedAnimation<Color>(Colors.white.withValues(alpha: 0.8)),
),
const Spacer(flex: 1),
SizedBox(
// Grow the result area height with the font scale to avoid clipping
height: () {
final h = 50.0 * _settings.resultTextScale + 20.0;
final limit = MediaQuery.of(context).size.height * 0.6; // cap to 60% of screen
final clamped = h.clamp(70.0, limit);
return clamped;
}(),
child: Visibility(
visible: _controller.isAnimating || _resultText != null,
maintainState: true,
maintainAnimation: true,
maintainSize: true,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
_controller.isAnimating ? '${_currentNumber ?? ''}' : (_resultText ?? ''),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Theme.of(context).brightness == Brightness.light ? Colors.white : Colors.black,
fontSize: 50.0 * _settings.resultTextScale,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
),
Expanded(
flex: 6,
child: Stack(
children: [
Positioned.fill(
child: CustomPaint(
painter: NumberRoulettePainter(
animationValue: _animation.value,
numbers: _orderedNumbers,
colorLight: _colorLight,
colorDark: _colorDark,
boardFontScale: _settings.rouletteTextScale,
),
),
),
Positioned.fill(
child: Align(
alignment: Alignment.center,
child: AnimatedOpacity(
opacity: _controller.isAnimating ? 0.0 : 1.0,
duration: const Duration(milliseconds: 600),
child: GestureDetector(
onTap: _onClickStart,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: const Color(0xFF000000).withValues(alpha: 0.6),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
localizations.rouletteStart,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white),
),
),
),
),
),
),
],
),
),
const Spacer(flex: 2),
]
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LayoutBuilder(
builder: (context, constraints) {
final int width = constraints.maxWidth.isFinite
? constraints.maxWidth.truncate()
: MediaQuery.of(context).size.width.truncate();
if (width > 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _updateBannerForWidth(width);
});
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 10),
if (_isAdLoaded && _adManager.bannerAd != null)
Center(
child: SizedBox(
width: _adManager.bannerAd!.size.width.toDouble(),
height: _adManager.bannerAd!.size.height.toDouble(),
child: AdWidget(ad: _adManager.bannerAd!),
),
),
],
);
},
),
)
],
),
),
);
},
);
}
}
class NumberRoulettePainter extends CustomPainter {
final double animationValue; // degrees
final List<int> numbers;
final List<Color> colorLight;
final List<Color> colorDark;
final double boardFontScale;
NumberRoulettePainter({
required this.animationValue,
required this.numbers,
required this.colorLight,
required this.colorDark,
this.boardFontScale = 1.0,
});
@override
void paint(Canvas canvas, Size size) {
if (numbers.isEmpty) return;
final double centerX = size.width / 2;
final double centerY = size.height / 2;
final double radius = min(centerX, centerY) * 0.8;
final Paint whitePaint = Paint()..color = Colors.white;
// 359-degree arc to create a 1-degree gap at the top
final double gap = pi / 180;
canvas.drawArc(
Rect.fromCircle(center: Offset(centerX, centerY), radius: radius + 10),
-pi / 2 + gap / 2,
2 * pi - gap,
true,
whitePaint,
);
double startAngle = animationValue * (pi / 180); // radians
final int count = numbers.length;
final double sweepAngle = (2 * pi) / count;
for (int i = 0; i < count; i++) {
final Paint segmentPaint = Paint()..color = colorLight[i % colorLight.length];
canvas.drawArc(
Rect.fromCircle(center: Offset(centerX, centerY), radius: radius),
startAngle,
sweepAngle,
true,
segmentPaint,
);
final Paint darkPaint = Paint()..color = colorDark[i % colorDark.length];
canvas.drawArc(
Rect.fromCircle(center: Offset(centerX, centerY), radius: radius / 2),
startAngle,
sweepAngle,
true,
darkPaint,
);
// draw number labels only if not too many
if (count < 360) {
final double textAngle = startAngle + sweepAngle / 2;
final double textRadius = radius * 0.8;
final double textX = centerX + textRadius * cos(textAngle);
final double textY = centerY + textRadius * sin(textAngle);
const double boardFontSize = 15.0;
final tp = TextPainter(
text: TextSpan(style: TextStyle(color: Colors.black, fontSize: boardFontSize * boardFontScale), text: numbers[i].toString()),
textDirection: TextDirection.ltr,
);
tp.layout();
canvas.save();
canvas.translate(textX, textY);
canvas.rotate(textAngle + pi / 2);
tp.paint(canvas, Offset(-tp.width / 2, -tp.height / 2));
canvas.restore();
}
startAngle += sweepAngle;
}
}
@override
bool shouldRepaint(covariant NumberRoulettePainter oldDelegate) {
return oldDelegate.animationValue != animationValue ||
!listEquals(oldDelegate.numbers, numbers) ||
oldDelegate.boardFontScale != boardFontScale;
}
}
class ThreePhaseRouletteCurve extends Curve {
final double easeInDuration;
final double linearDuration;
final double easeOutDuration;
const ThreePhaseRouletteCurve({
required this.easeInDuration,
required this.linearDuration,
required this.easeOutDuration,
});
@override
double transformInternal(double t) {
final total = easeInDuration + linearDuration + easeOutDuration;
final easeInFrac = easeInDuration / total;
final linearFrac = linearDuration / total;
final distEaseIn = 0.5 * easeInDuration;
final distLinear = 1.0 * linearDuration;
final distEaseOut = 0.5 * easeOutDuration;
final totalDist = distEaseIn + distLinear + distEaseOut;
if (t < easeInFrac) {
final time = t * total;
final distance = 0.5 * time * time / easeInDuration;
return distance / totalDist;
} else if (t < easeInFrac + linearFrac) {
final time = (t - easeInFrac) * total;
final distance = distEaseIn + time;
return distance / totalDist;
} else {
final time = (t - easeInFrac - linearFrac) * total;
final v0 = 1.0;
final a = -v0 / easeOutDuration;
final distance = distEaseIn + distLinear + (v0 * time + 0.5 * a * time * time);
return distance / totalDist;
}
}
}
lib/models.dart
import 'package:equatable/equatable.dart';
class NumberSettings extends Equatable {
final int minNumber;
final int maxNumber;
final bool positionReverse;
final bool positionRandom;
final bool fixBackground;
final bool shortRotation;
final bool speechNumber;
final String speechVoice;
final String speechLocale;
final double maxSpeedDuration; // seconds for linear phase
final int themeNumber; // ThemeMode.index
final String localeLanguage;
final double resultTextScale; // 0.5 .. 10.0 (50%..1000%)
final double rouletteTextScale; // 0.5 .. 10.0 (50%..1000%)
const NumberSettings({
required this.minNumber,
required this.maxNumber,
this.positionReverse = false,
this.positionRandom = false,
this.fixBackground = false,
this.shortRotation = false,
this.speechNumber = true,
this.speechVoice = '',
this.speechLocale = '',
this.maxSpeedDuration = 5.0,
this.themeNumber = 0,
this.localeLanguage = '',
this.resultTextScale = 1.0,
this.rouletteTextScale = 1.0,
});
NumberSettings copyWith({
int? minNumber,
int? maxNumber,
bool? positionReverse,
bool? positionRandom,
bool? fixBackground,
bool? shortRotation,
bool? speechNumber,
String? speechVoice,
String? speechLocale,
double? maxSpeedDuration,
int? themeNumber,
String? localeLanguage,
double? resultTextScale,
double? rouletteTextScale,
}) {
return NumberSettings(
minNumber: minNumber ?? this.minNumber,
maxNumber: maxNumber ?? this.maxNumber,
positionReverse: positionReverse ?? this.positionReverse,
positionRandom: positionRandom ?? this.positionRandom,
fixBackground: fixBackground ?? this.fixBackground,
shortRotation: shortRotation ?? this.shortRotation,
speechNumber: speechNumber ?? this.speechNumber,
speechVoice: speechVoice ?? this.speechVoice,
speechLocale: speechLocale ?? this.speechLocale,
maxSpeedDuration: maxSpeedDuration ?? this.maxSpeedDuration,
themeNumber: themeNumber ?? this.themeNumber,
localeLanguage: localeLanguage ?? this.localeLanguage,
resultTextScale: resultTextScale ?? this.resultTextScale,
rouletteTextScale: rouletteTextScale ?? this.rouletteTextScale,
);
}
@override
List<Object?> get props => [
minNumber,
maxNumber,
positionReverse,
positionRandom,
fixBackground,
shortRotation,
speechNumber,
speechVoice,
speechLocale,
maxSpeedDuration,
themeNumber,
localeLanguage,
resultTextScale,
rouletteTextScale,
];
}
lib/setting_screen.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:numberroulette/ad_manager.dart';
import 'package:numberroulette/models.dart';
import 'package:numberroulette/l10n/gen/app_localizations.dart';
class SettingScreen extends StatefulWidget {
final NumberSettings initialSettings;
const SettingScreen({super.key, required this.initialSettings});
@override
State<SettingScreen> createState() => _SettingScreenState();
}
class _SettingScreenState extends State<SettingScreen> {
late NumberSettings _currentSettings;
late ThemeMode _tempThemeMode;
String? _selectedLocaleTag;
final TextEditingController _minController = TextEditingController();
final TextEditingController _maxController = TextEditingController();
// Text size controls (sliders) are defined below
List<Map<String, String>> _speechVoices = [];
Map<String, String>? _selectedSpeechVoice;
late AdManager _adManager;
bool _isAdLoaded = false;
bool _bannerRequested = false; // kept for compatibility, no longer used for loading
int? _lastBannerWidthDp;
// Allowed percent options for font scales
static const List<int> _percentOptions = [
41, 51, 64, 80, 100, 120, 144, 173, 207, 249, 299, 358, 430, 516, 619, 743, 892, 1070
];
int _nearestIndexForScale(double scale) {
final target = (scale * 100).round();
int bestIdx = 0;
int bestDiff = 1 << 30;
for (int i = 0; i < _percentOptions.length; i++) {
final d = (target - _percentOptions[i]).abs();
if (d < bestDiff) {
bestDiff = d;
bestIdx = i;
}
}
return bestIdx;
}
double _scaleForIndex(int index) {
final i = index.clamp(0, _percentOptions.length - 1).toInt();
return _percentOptions[i] / 100.0;
}
// Language options (BCP-47 tags)
final Map<String, String> languageOptions = const {
'en': 'English',
'fr': 'Français',
'it': 'Italiano',
'de': 'Deutsch',
'es': 'Español',
'es-419': 'Español (Latinoamérica)',
'id': 'Indonesia',
'uk': 'Українська',
'nl': 'Nederlands',
'el': 'Ελληνικά',
'sv': 'Svenska',
'th': 'ไทย',
'cs': 'Čeština',
'da': 'Dansk',
'tr': 'Türkçe',
'nb': 'Norsk (Bokmål)',
'hu': 'Magyar',
'fi': 'Suomi',
'bg': 'Български',
'vi': 'Tiếng Việt',
'pt-BR': 'Português (Brasil)',
'pt-PT': 'Português (Portugal)',
'pl': 'Polski',
'ro': 'Română',
'ru': 'Русский',
'zh-Hans': '中文(简体)',
'zh-Hant': '中文(繁體)',
'ja': '日本語',
'ko': '한국어',
'ar': 'العربية',
};
@override
void initState() {
super.initState();
_currentSettings = widget.initialSettings;
_tempThemeMode = ThemeMode.values[min(_currentSettings.themeNumber, ThemeMode.values.length - 1)];
_selectedLocaleTag = _currentSettings.localeLanguage.isEmpty ? null : _currentSettings.localeLanguage;
_minController.text = _currentSettings.minNumber.toString();
_maxController.text = _currentSettings.maxNumber.toString();
// Text sizes are fixed; no initialization needed
_adManager = AdManager();
_initTts();
}
void _updateBannerForWidth(int widthDp) {
if (widthDp <= 0) return;
if (_lastBannerWidthDp == widthDp && _isAdLoaded && _adManager.bannerAd != null &&
_adManager.bannerAd!.size.width == widthDp) {
return;
}
_lastBannerWidthDp = widthDp;
_adManager.loadAdaptiveBannerAd(widthDp, () {
if (mounted) setState(() => _isAdLoaded = true);
});
}
Future<void> _initTts() async {
final tts = FlutterTts();
_speechVoices = (await tts.getVoices as List<dynamic>)
.map((e) => {'name': e['name'].toString(), 'locale': e['locale'].toString().replaceAll('_', '-')})
.toList();
_speechVoices.sort((a, b) => a['locale']!.compareTo(b['locale']!));
Map<String, String>? foundVoice;
if (_currentSettings.speechVoice.isNotEmpty && _currentSettings.speechLocale.isNotEmpty) {
final normalizedSettingsLocale = _currentSettings.speechLocale.replaceAll('_', '-');
try {
foundVoice = _speechVoices.firstWhere((voice) =>
voice['name'] == _currentSettings.speechVoice && voice['locale'] == normalizedSettingsLocale);
} catch (_) {}
}
setState(() => _selectedSpeechVoice = foundVoice);
}
@override
void dispose() {
_minController.dispose();
_maxController.dispose();
// No text size controllers
_adManager.dispose();
super.dispose();
}
void _onApply() {
int minNumber = int.tryParse(_minController.text.trim()) ?? 0;
int maxNumber = int.tryParse(_maxController.text.trim()) ?? (minNumber + 1);
if (minNumber < 0) minNumber = 0;
if (maxNumber <= minNumber) maxNumber = minNumber + 1;
if (maxNumber - minNumber >= 3600) maxNumber = minNumber + 3599;
final newSettings = _currentSettings.copyWith(
minNumber: minNumber,
maxNumber: maxNumber,
speechVoice: _selectedSpeechVoice?['name'] ?? '',
speechLocale: _selectedSpeechVoice?['locale'] ?? '',
themeNumber: _tempThemeMode.index,
localeLanguage: _selectedLocaleTag ?? '',
);
Navigator.pop(context, newSettings);
}
void _onCancel() {
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
leading: IconButton(icon: const Icon(Icons.close), onPressed: _onCancel),
title: const Text(''),
actions: [
IconButton(icon: const Icon(Icons.check), onPressed: _onApply),
const SizedBox(width: 24),
],
),
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SafeArea(
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.numberRange, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _minController,
decoration: InputDecoration(
labelText: l.min,
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _maxController,
decoration: InputDecoration(
labelText: l.max,
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
],
),
],
),
),
const Divider(height: 40, thickness: 1),
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(l.textSizeAdjustResult),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Slider(
min: 0,
max: (_percentOptions.length - 1).toDouble(),
divisions: _percentOptions.length - 1,
value: _nearestIndexForScale(_currentSettings.resultTextScale).toDouble(),
label: '${_percentOptions[_nearestIndexForScale(_currentSettings.resultTextScale)]}%',
onChanged: (v) {
final idx = v.round();
setState(() {
_currentSettings = _currentSettings.copyWith(resultTextScale: _scaleForIndex(idx));
});
},
),
),
Text(
'${_percentOptions[_nearestIndexForScale(_currentSettings.resultTextScale)]}%',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
],
),
),
const Divider(height: 20, thickness: 0),
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(l.textSizeAdjustRoulette),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Slider(
min: 0,
max: (_percentOptions.length - 1).toDouble(),
divisions: _percentOptions.length - 1,
value: _nearestIndexForScale(_currentSettings.rouletteTextScale).toDouble(),
label: '${_percentOptions[_nearestIndexForScale(_currentSettings.rouletteTextScale)]}%',
onChanged: (v) {
final idx = v.round();
setState(() {
_currentSettings = _currentSettings.copyWith(rouletteTextScale: _scaleForIndex(idx));
});
},
),
),
Text(
'${_percentOptions[_nearestIndexForScale(_currentSettings.rouletteTextScale)]}%',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
],
),
),
const Divider(height: 40, thickness: 1),
SwitchListTile(
contentPadding: const EdgeInsets.only(left: 16.0,right: 12.0),
title: Text(l.reverseOrder),
value: _currentSettings.positionReverse,
onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(positionReverse: v, positionRandom: v ? false : _currentSettings.positionRandom)),
),
SwitchListTile(
contentPadding: const EdgeInsets.only(left: 16.0,right: 12.0),
title: Text(l.randomizeOrder),
value: _currentSettings.positionRandom,
onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(positionRandom: v, positionReverse: v ? false : _currentSettings.positionReverse)),
),
SwitchListTile(
contentPadding: const EdgeInsets.only(left: 16.0,right: 12.0),
title: Text(l.fixBackgroundWhileSpinning),
value: _currentSettings.fixBackground,
onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(fixBackground: v)),
),
const Divider(height: 40, thickness: 1),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16,right: 16),
child: Text(l.rotationTime, style: Theme.of(context).textTheme.titleMedium),
),
const SizedBox(height: 4),
// Removed description text as requested
Row(
children: [
Expanded(
child: Slider(
value: _currentSettings.maxSpeedDuration,
min: 1.0,
max: 15.0,
divisions: 14,
label: _currentSettings.maxSpeedDuration.toStringAsFixed(1),
onChanged: (double value) {
setState(() {
_currentSettings = _currentSettings.copyWith(maxSpeedDuration: value);
});
},
),
),
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Text(
_currentSettings.maxSpeedDuration.toInt().toString().padLeft(2, '0'),
style: Theme.of(context).textTheme.titleMedium,
),
)
],
),
SwitchListTile(
contentPadding: const EdgeInsets.only(left: 16,right: 12),
title: Text(l.shortenRotation),
value: _currentSettings.shortRotation,
onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(shortRotation: v)),
),
],
),
const Divider(height: 40, thickness: 1),
SwitchListTile(
contentPadding: const EdgeInsets.only(left: 16.0,right: 12.0),
title: Text(l.speechResult),
value: _currentSettings.speechNumber,
onChanged: (v) => setState(() => _currentSettings = _currentSettings.copyWith(speechNumber: v)),
),
Padding(
padding: const EdgeInsets.only(left: 16.0,right: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.speechVoice),
const SizedBox(height: 4),
DropdownButton<Map<String, String>>(
value: _selectedSpeechVoice,
isExpanded: true,
items: _speechVoices.map((v) => DropdownMenuItem<Map<String, String>>(
value: v,
child: Text('${v['locale']} - ${v['name']}'),
)).toList(),
onChanged: (nv) => setState(() => _selectedSpeechVoice = nv),
),
],
),
),
const Divider(height: 40, thickness: 1),
ListTile(
title: Text(l.language),
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;
});
},
),
),
const Divider(height: 40, thickness: 1),
ListTile(
title: Text(l.theme),
trailing: DropdownButton<ThemeMode>(
value: _tempThemeMode,
items: [
DropdownMenuItem(value: ThemeMode.system, child: Text(l.systemDefault)),
DropdownMenuItem(value: ThemeMode.light, child: Text(l.lightTheme)),
DropdownMenuItem(value: ThemeMode.dark, child: Text(l.darkTheme)),
],
onChanged: (v) {
if (v != null) {
setState(() {
_tempThemeMode = v;
_currentSettings = _currentSettings.copyWith(themeNumber: v.index);
});
}
},
),
),
const Divider(height: 40, thickness: 1),
],
),
),
),
const SizedBox(height: 10),
LayoutBuilder(
builder: (context, constraints) {
final int width = constraints.maxWidth.isFinite
? constraints.maxWidth.truncate()
: MediaQuery.of(context).size.width.truncate();
if (width > 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _updateBannerForWidth(width);
});
}
return _isAdLoaded && _adManager.bannerAd != null
? Center(
child: SizedBox(
width: _adManager.bannerAd!.size.width.toDouble(),
height: _adManager.bannerAd!.size.height.toDouble(),
child: AdWidget(ad: _adManager.bannerAd!),
),
)
: const SizedBox.shrink();
},
),
],
),
),
),
);
}
}