pubspec.yaml
name: roulette
description: "Roulette"
# 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.1.2+30
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: #多言語ライブラリの本体 # .arbファイルを更新したら flutter gen-l10n
sdk: flutter
intl: ^0.20.2 #多言語やフォーマッタなどの関連ライブラリ
google_mobile_ads: ^6.0.0
flutter_tts: ^4.0.2
just_audio: ^0.10.4
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'
# The following section is specific to Flutter packages.
flutter:
generate: true
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/image/
# 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 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'l10n/app_localizations.dart';
import 'package:roulette/models.dart';
import 'package:roulette/ad_manager.dart';
import 'package:roulette/setting_screen.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
));
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();
}
_loadThemeAndLocale() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
int themeNumber = prefs.getInt('themeNumber') ?? 0;
String localeLanguage = prefs.getString('localeLanguage') ?? '';
setState(() {
_themeMode = ThemeMode.values[min(themeNumber, ThemeMode.values.length - 1)];
_locale = localeLanguage.isNotEmpty ? _localeFromTag(localeLanguage) : null; // Use system locale when empty
});
}
void _setTheme(int themeNumber) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setInt('themeNumber', themeNumber);
setState(() {
_themeMode = ThemeMode.values[min(themeNumber, ThemeMode.values.length - 1)];
});
}
void _setLocale(String? languageCode) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
if (languageCode != null && languageCode.isNotEmpty) {
await prefs.setString('localeLanguage', languageCode);
setState(() {
_locale = _localeFromTag(languageCode);
});
} else {
await prefs.remove('localeLanguage');
setState(() {
_locale = null; // Use system locale
});
}
}
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: 'Roulette App',
themeMode: _themeMode,
theme: ThemeData(
primarySwatch: Colors.blueGrey,
brightness: Brightness.light,
scaffoldBackgroundColor: Colors.white,
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF9eabfa), // back_nav
foregroundColor: Colors.white,
systemOverlayStyle: SystemUiOverlayStyle.dark, // dark icons on light app bar
),
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, // Buttons background
foregroundColor: Colors.white, // Buttons text color
minimumSize: const Size.fromHeight(34), // 34dp height
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero, // No rounded corners
),
),
),
),
darkTheme: ThemeData(
primarySwatch: Colors.blueGrey,
brightness: Brightness.dark,
scaffoldBackgroundColor: Colors.black,
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xff333333), // back_nav
foregroundColor: Colors.white,
systemOverlayStyle: SystemUiOverlayStyle.light, // light icons on dark app bar
),
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, // Buttons background
foregroundColor: Colors.white, // Buttons text color
minimumSize: const Size.fromHeight(34), // 34dp height
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero, // No rounded corners
),
),
),
),
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 Settings _settings;
bool _isLoading = true;
late AnimationController _controller;
late Animation<double> _animation;
String? _rouletteResult;
String? _currentItemName;
Color? _currentBackgroundColor;
final _random = Random();
final Color _fixedBgColor = const Color(0xFFaaaaaa);
List<Map<String, String>> _availableTtsVoices = [];
late AdManager _adManager;
bool _isAdLoaded = false;
int? _lastBannerWidthDp;
// Roulette Colors from CustomSurfaceView.kt
final List<Color> _rouletteColors = [
const Color.fromARGB(255, 234, 123, 132),
const Color.fromARGB(255, 240, 196, 123),
const Color.fromARGB(255, 247, 239, 123),
const Color.fromARGB(255, 192, 217, 139),
const Color.fromARGB(255, 123, 197, 156),
const Color.fromARGB(255, 123, 201, 235),
const Color.fromARGB(255, 123, 173, 211),
const Color.fromARGB(255, 138, 139, 189),
const Color.fromARGB(255, 194, 127, 186),
const Color.fromARGB(255, 233, 123, 185),
];
final List<Color> _rouletteDarkColors = [
const Color.fromARGB(255, 222, 0, 17),
const Color.fromARGB(255, 234, 145, 0),
const Color.fromARGB(255, 247, 232, 0),
const Color.fromARGB(255, 137, 188, 30),
const Color.fromARGB(255, 0, 147, 66),
const Color.fromARGB(255, 0, 154, 225),
const Color.fromARGB(255, 0, 101, 176),
const Color.fromARGB(255, 28, 31, 131),
const Color.fromARGB(255, 140, 7, 126),
const Color.fromARGB(255, 220, 0, 123),
];
@override
void initState() {
super.initState();
_adManager = AdManager();
_initAsyncDependencies();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 10),
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_determineWinner();
}
});
_animation = Tween<double>(begin: 0, end: 360 * 30).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutCubic, // Dummy curve, will be replaced in _onClickStart
));
}
Future<void> _initAsyncDependencies() async {
_prefs = await SharedPreferences.getInstance();
_settings = _loadSettings();
await _initTts();
_updateColorAndItemNameForAngle(0.0); // Set initial color and item name
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);
});
}
Settings _loadSettings() {
List<RouletteItem> itemStates = [];
for (int i = 1; i <= 20; i++) {
String name = _prefs.getString('itemName$i') ?? '';
double rate = _prefs.getDouble('itemRate$i') ?? 1.0;
itemStates.add(RouletteItem(name, rate));
}
// If no items are loaded, set default items
if (itemStates.every((item) => item.name.isEmpty)) {
itemStates = Settings.defaultItemStates();
// Save default items
for (int i = 0; i < itemStates.length; i++) {
_prefs.setString('itemName${i + 1}', itemStates[i].name);
_prefs.setDouble('itemRate${i + 1}', itemStates[i].rate);
}
}
return Settings(
itemStates: itemStates,
itemSplit: (_prefs.getInt('itemSplit') ?? 0) == 1,
fixBackground: (_prefs.getInt('fixBackground') ?? 0) == 1,
shortenRotation: (_prefs.getInt('shortenRotation') ?? 0) == 1,
maxSpeedDuration: _prefs.getDouble('maxSpeedDuration') ?? 5.0,
speechResult: (_prefs.getInt('speechResult') ?? 1) == 1,
speechVoice: _prefs.getString('speechVoice') ?? '',
speechLocale: _prefs.getString('speechLocale') ?? '',
themeNumber: _prefs.getInt('themeNumber') ?? 0,
localeLanguage: _prefs.getString('localeLanguage') ?? '',
);
}
Future<void> _saveSettings() async {
for (int i = 0; i < _settings.itemStates.length; i++) {
await _prefs.setString('itemName${i + 1}', _settings.itemStates[i].name);
await _prefs.setDouble('itemRate${i + 1}', _settings.itemStates[i].rate);
}
await _prefs.setInt('itemSplit', _settings.itemSplit ? 1 : 0);
await _prefs.setInt('fixBackground', _settings.fixBackground ? 1 : 0);
await _prefs.setInt('shortenRotation', _settings.shortenRotation ? 1 : 0);
await _prefs.setDouble('maxSpeedDuration', _settings.maxSpeedDuration);
await _prefs.setInt('speechResult', _settings.speechResult ? 1 : 0);
await _prefs.setString('speechVoice', _settings.speechVoice);
await _prefs.setString('speechLocale', _settings.speechLocale);
await _prefs.setInt('themeNumber', _settings.themeNumber);
await _prefs.setString('localeLanguage', _settings.localeLanguage);
}
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 (e) {
// Voice not found, selectedVoice remains null
}
}
if (selectedVoice != null) {
await flutterTts.setVoice(selectedVoice);
} else {
await flutterTts.setLanguage("en-US"); // Default language if no specific voice is selected or found
}
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();
}
List<RouletteItem> _getActiveItems() {
final activeItems = _settings.itemStates.where((item) => item.name.isNotEmpty).toList();
if (_settings.itemSplit && activeItems.isNotEmpty) {
activeItems.addAll(List.from(activeItems));
}
return activeItems;
}
void _onClickStart() {
setState(() {
_rouletteResult = null; // Clear previous result
_currentItemName = '...';
});
// Define animation phases duration
final double scale = _settings.shortenRotation ? 0.1 : 1.0;
final double easeInDuration = 1.0 * scale; // seconds
final double easeOutDuration = 8.0 * scale; // seconds
final double linearDuration = _settings.maxSpeedDuration * scale;
final double totalDuration = easeInDuration + linearDuration + easeOutDuration;
_controller.duration = Duration(milliseconds: (totalDuration * 1000).round());
// Adjust rotation amount based on duration to keep speed consistent
const double baseEaseIn = 1.0;
const double baseLinearDuration = 5.0; // Default linear duration
const double baseEaseOut = 8.0;
const double baseTotalDuration = baseEaseIn + baseLinearDuration + baseEaseOut;
const double baseRotationAmount = 360 * 28; // A base rotation amount for the base duration
final double targetRotationAmount = baseRotationAmount * (totalDuration / baseTotalDuration);
// Add a bit of randomness to the final position
final double randomExtraRotation = 360 * (_random.nextDouble() - 0.5); // +/- 180 degrees
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 _updateCurrentItem() {
final double currentAngle = _animation.value;
double effectiveAngle = (360 - (currentAngle % 360) + 270) % 360;
double currentAngleSum = 0.0;
final List<RouletteItem> activeItems = _getActiveItems();
double totalRate = activeItems.fold(0.0, (sum, item) => sum + item.rate);
if (totalRate == 0) {
_currentItemName = AppLocalizations.of(context)!.noItemsToSpin;
return;
}
for (int i = 0; i < activeItems.length; i++) {
final item = activeItems[i];
final double sweepAngle = (item.rate / totalRate) * 360;
if (effectiveAngle >= currentAngleSum && effectiveAngle < currentAngleSum + sweepAngle) {
final originalItemsCount = _settings.itemStates.where((i) => i.name.isNotEmpty).length;
if (originalItemsCount == 0) return;
_currentItemName = item.name;
final Color segColor = _rouletteColors[i % originalItemsCount % _rouletteColors.length];
if (_controller.isAnimating && _settings.fixBackground) {
_currentBackgroundColor = _fixedBgColor;
} else {
_currentBackgroundColor = segColor;
}
return;
}
currentAngleSum += sweepAngle;
}
}
void _determineWinner() {
final double finalAngle = _animation.value; // This is 0-360 degrees
// Assuming the pointer is at the "top" of the wheel, which is 0 degrees if we consider the top as the reference.
// The animation value is the total rotation.
// We need to find which segment is at the 0-degree mark after the rotation.
// The angle on the unrotated wheel that is now at the pointer (top = 270 degrees).
// If the wheel rotated by `finalAngle` clockwise, then the segment that was originally at `(270 - finalAngle) % 360` is now at the top.
// Our drawing logic has 0 degrees on the right. So top is 270.
double effectiveAngle = (360 - (finalAngle % 360) + 270) % 360;
double currentAngle = 0.0;
final List<RouletteItem> activeItems = _getActiveItems();
double totalRate = activeItems.fold(0.0, (sum, item) => sum + item.rate);
if (totalRate == 0) {
setState(() {
_rouletteResult = AppLocalizations.of(context)!.noItemsToSpin;
});
return;
}
for (int i = 0; i < activeItems.length; i++) {
final item = activeItems[i];
final double sweepAngle = (item.rate / totalRate) * 360; // in degrees
if (effectiveAngle >= currentAngle && effectiveAngle < currentAngle + sweepAngle) {
setState(() {
final originalItemsCount = _settings.itemStates.where((i) => i.name.isNotEmpty).length;
if (originalItemsCount == 0) return;
_rouletteResult = item.name;
_currentItemName = item.name;
_currentBackgroundColor = _rouletteColors[i % originalItemsCount % _rouletteColors.length];
if (_settings.speechResult) {
flutterTts.speak(item.name);
}
});
return;
}
currentAngle += sweepAngle;
}
setState(() {
_rouletteResult = AppLocalizations.of(context)!.errorDeterminingWinner;
});
}
void _updateColorAndItemNameForAngle(double angle) {
// This method calculates the color and item name for a given angle.
double effectiveAngle = (360 - (angle % 360) + 270) % 360;
double currentAngleSum = 0.0;
final List<RouletteItem> activeItems = _getActiveItems();
double totalRate = activeItems.fold(0.0, (sum, item) => sum + item.rate);
if (totalRate == 0) {
setState(() {
_currentItemName = AppLocalizations.of(context)?.noItemsToSpin;
_currentBackgroundColor = null; // Or a default color
});
return;
}
for (int i = 0; i < activeItems.length; i++) {
final item = activeItems[i];
final double sweepAngle = (item.rate / totalRate) * 360;
if (effectiveAngle >= currentAngleSum && effectiveAngle < currentAngleSum + sweepAngle) {
setState(() {
final originalItemsCount = _settings.itemStates.where((i) => i.name.isNotEmpty).length;
if (originalItemsCount == 0) return;
// When the wheel is not spinning, this sets the result.
if (!_controller.isAnimating) {
_rouletteResult = item.name;
}
_currentItemName = item.name;
_currentBackgroundColor = _rouletteColors[i % originalItemsCount % _rouletteColors.length];
});
return;
}
currentAngleSum += sweepAngle;
}
}
void _onClickSetting() async {
final updatedSettings = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SettingScreen(initialSettings: _settings),
),
);
if (updatedSettings != null) {
setState(() {
_settings = updatedSettings;
});
await _saveSettings();
widget.setTheme(_settings.themeNumber);
widget.setLocale(_settings.localeLanguage.isEmpty ? null : _settings.localeLanguage);
await _initTts(); // Re-initialize TTS to apply new voice settings
_updateColorAndItemNameForAngle(_animation.value); // Recalculate color and item name for the current angle
}
}
@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, child) {
_updateCurrentItem();
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: [
// Progress bar below the app bar
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(
height: 70.0,
child: Visibility(
visible: _controller.isAnimating || _rouletteResult != null,
maintainState: true,
maintainAnimation: true,
maintainSize: true,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
_currentItemName ?? _rouletteResult ?? '',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Theme.of(context).brightness == Brightness.light ? Colors.white : Colors.black,
),
textAlign: TextAlign.center,
),
),
),
),
Expanded(
flex: 6,
child: Stack(
children: [
Positioned.fill(
child: Center(
child: CustomPaint(
painter: RoulettePainter(
animationValue: _animation.value,
activeItems: _getActiveItems(),
settings: _settings,
rouletteColors: _rouletteColors,
rouletteDarkColors: _rouletteDarkColors,
),
child: Container(),
),
),
),
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 RoulettePainter extends CustomPainter {
final double animationValue;
final List<RouletteItem> activeItems;
final Settings settings;
final List<Color> rouletteColors;
final List<Color> rouletteDarkColors;
RoulettePainter({
required this.animationValue,
required this.activeItems,
required this.settings,
required this.rouletteColors,
required this.rouletteDarkColors,
});
@override
void paint(Canvas canvas, Size size) {
// Implement roulette drawing logic here based on CustomSurfaceView.kt
// This is a placeholder for now.
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;
// Draw a 359-degree arc to create a 1-degree gap at the top.
final double gap = pi / 180; // 1 degree in radians
canvas.drawArc(
Rect.fromCircle(center: Offset(centerX, centerY), radius: radius + 10),
-pi / 2 + gap / 2, // Start angle (top is -pi/2), offset by half the gap
2 * pi - gap, // Sweep angle (359 degrees)
true,
whitePaint,
);
double startAngle = animationValue * (pi / 180); // Convert degrees to radians
// Filter out empty items and calculate total rate for active items
double totalRate = activeItems.fold(0.0, (sum, item) => sum + item.rate);
if (totalRate == 0) return;
final originalItemsCount = settings.itemStates.where((i) => i.name.isNotEmpty).length;
if (originalItemsCount == 0) return;
for (int i = 0; i < activeItems.length; i++) {
final item = activeItems[i];
final double sweepAngle = (item.rate / totalRate) * 2 * pi;
final Paint segmentPaint = Paint()..color = rouletteColors[i % originalItemsCount % rouletteColors.length];
canvas.drawArc(
Rect.fromCircle(center: Offset(centerX, centerY), radius: radius),
startAngle,
sweepAngle,
true,
segmentPaint,
);
final Paint darkSegmentPaint = Paint()..color = rouletteDarkColors[i % originalItemsCount % rouletteDarkColors.length];
canvas.drawArc(
Rect.fromCircle(center: Offset(centerX, centerY), radius: radius / 2),
startAngle,
sweepAngle,
true,
darkSegmentPaint,
);
// Draw text
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);
final TextPainter textPainter = TextPainter(
text: TextSpan(
text: item.name,
style: const TextStyle(color: Colors.black, fontSize: 16),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
canvas.save();
canvas.translate(textX, textY);
canvas.rotate(textAngle + pi / 2); // Rotate text to align with segment
textPainter.paint(canvas, Offset(-textPainter.width / 2, -textPainter.height / 2));
canvas.restore();
startAngle += sweepAngle;
}
}
@override
bool shouldRepaint(covariant RoulettePainter oldDelegate) {
return oldDelegate.animationValue != animationValue ||
!listEquals(oldDelegate.activeItems, activeItems) ||
oldDelegate.settings != settings;
}
}
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 totalDuration = easeInDuration + linearDuration + easeOutDuration;
final easeInFraction = easeInDuration / totalDuration;
final linearFraction = linearDuration / totalDuration;
// Calculate the total distance traveled in each phase if the max speed is 1.0
final distEaseIn = 0.5 * easeInDuration; // Area of triangle
final distLinear = 1.0 * linearDuration;
final distEaseOut = 0.5 * easeOutDuration; // Area of triangle
final totalDistance = distEaseIn + distLinear + distEaseOut;
if (t < easeInFraction) {
// Phase 1: Ease-in (Quadratic ease-in, v = at)
final timeInPhase = t * totalDuration;
final distance = 0.5 * timeInPhase * timeInPhase / easeInDuration;
return distance / totalDistance;
} else if (t < easeInFraction + linearFraction) {
// Phase 2: Linear
final timeInPhase = (t - easeInFraction) * totalDuration;
final distance = distEaseIn + timeInPhase;
return distance / totalDistance;
} else {
// Phase 3: Ease-out (Quadratic ease-out)
final timeInPhase = (t - easeInFraction - linearFraction) * totalDuration;
final initialVelocity = 1.0;
final acceleration = -initialVelocity / easeOutDuration;
final distance = distEaseIn + distLinear + (initialVelocity * timeInPhase + 0.5 * acceleration * timeInPhase * timeInPhase);
return distance / totalDistance;
}
}
}
lib/models.dart
import 'package:equatable/equatable.dart';
class RouletteItem extends Equatable {
final String name;
final double rate;
const RouletteItem(this.name, this.rate);
@override
List<Object?> get props => [name, rate];
RouletteItem copyWith({String? name,double? rate}) {
return RouletteItem(
name ?? this.name,
rate ?? this.rate,
);
}
Map<String, dynamic> toJson() => {
'name': name,
'rate': rate,
};
factory RouletteItem.fromJson(Map<String, dynamic> json) {
return RouletteItem(json['name'], json['rate']);
}
}
class Settings extends Equatable {
final List<RouletteItem> itemStates;
final bool itemSplit;
final bool fixBackground;
final bool shortenRotation;
final double maxSpeedDuration;
final bool speechResult;
final String speechVoice;
final String speechLocale;
final int themeNumber;
final String localeLanguage;
const Settings({
required this.itemStates,
this.itemSplit = false,
this.fixBackground = false,
this.shortenRotation = false,
this.maxSpeedDuration = 5.0,
this.speechResult = true,
this.speechVoice = '',
this.speechLocale = '',
this.themeNumber = 0,
this.localeLanguage = '',
});
Settings copyWith({
List<RouletteItem>? itemStates,
bool? itemSplit,
bool? fixBackground,
bool? shortenRotation,
double? maxSpeedDuration,
bool? speechResult,
String? speechVoice,
String? speechLocale,
int? themeNumber,
String? localeLanguage,
}) {
return Settings(
itemStates: itemStates ?? this.itemStates,
itemSplit: itemSplit ?? this.itemSplit,
fixBackground: fixBackground ?? this.fixBackground,
shortenRotation: shortenRotation ?? this.shortenRotation,
maxSpeedDuration: maxSpeedDuration ?? this.maxSpeedDuration,
speechResult: speechResult ?? this.speechResult,
speechVoice: speechVoice ?? this.speechVoice,
speechLocale: speechLocale ?? this.speechLocale,
themeNumber: themeNumber ?? this.themeNumber,
localeLanguage: localeLanguage ?? this.localeLanguage,
);
}
@override
List<Object?> get props => [
itemStates,
itemSplit,
fixBackground,
shortenRotation,
maxSpeedDuration,
speechResult,
speechVoice,
speechLocale,
themeNumber,
localeLanguage,
];
// Default settings for initial load
static List<RouletteItem> defaultItemStates() {
return [
const RouletteItem('Item 1', 1.0),
const RouletteItem('Item 2', 1.0),
const RouletteItem('Item 3', 1.0),
const RouletteItem('Item 4', 1.0),
const RouletteItem('Item 5', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
const RouletteItem('', 1.0),
];
}
}
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 'l10n/app_localizations.dart';
import 'package:roulette/ad_manager.dart';
import 'package:roulette/models.dart';
class SettingScreen extends StatefulWidget {
final Settings initialSettings;
const SettingScreen({super.key, required this.initialSettings});
@override
State<SettingScreen> createState() => _SettingScreenState();
}
class _SettingScreenState extends State<SettingScreen> {
late Settings _currentSettings;
late FlutterTts flutterTts;
late ThemeMode _tempThemeMode;
String? _selectedLocaleTag;
List<Map<String, String>> _speechVoices = [];
Map<String, String>? _selectedSpeechVoice;
final List<TextEditingController> _nameControllers = [];
final List<TextEditingController> _rateControllers = [];
late AdManager _adManager;
bool _isAdLoaded = false;
int? _lastBannerWidthDp;
int _visibleItemCount = 5;
final Map<String, String> languageOptions = const {
'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': '繁體中文',
'ar': 'العربية',
};
@override
void initState() {
super.initState();
_currentSettings = widget.initialSettings;
_adManager = AdManager();
final lastNonEmptyIndex = widget.initialSettings.itemStates.lastIndexWhere((item) => item.name.isNotEmpty);
final itemsToShow = lastNonEmptyIndex + 1;
_visibleItemCount = max(5, itemsToShow);
_tempThemeMode = ThemeMode.values[_currentSettings.themeNumber];
_selectedLocaleTag = _currentSettings.localeLanguage.isEmpty ? null : _currentSettings.localeLanguage;
for (int i = 0; i < _currentSettings.itemStates.length; i++) {
_nameControllers.add(TextEditingController(text: _currentSettings.itemStates[i].name));
_rateControllers.add(TextEditingController(text: _currentSettings.itemStates[i].rate.toString()));
}
_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);
});
}
void _incrementVisibleItems() {
setState(() {
_visibleItemCount = min(20, _visibleItemCount + 1);
});
}
void _decrementVisibleItems() {
setState(() {
if (_visibleItemCount > 5) {
final newCount = _visibleItemCount - 1;
_nameControllers[newCount].text = '';
_rateControllers[newCount].text = '1.0';
_visibleItemCount = newCount;
}
});
}
Future<void> _initTts() async {
flutterTts = FlutterTts();
_speechVoices = (await flutterTts.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']!));
setState(() {
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 (e) {
// Voice not found, foundVoice remains null
}
}
_selectedSpeechVoice = foundVoice;
});
}
@override
void dispose() {
for (var controller in _nameControllers) {
controller.dispose();
}
for (var controller in _rateControllers) {
controller.dispose();
}
_adManager.dispose();
super.dispose();
}
void _onApply() {
final newItemStates = List<RouletteItem>.generate(
_currentSettings.itemStates.length,
(i) => _currentSettings.itemStates[i].copyWith(
name: _nameControllers[i].text,
rate: double.tryParse(_rateControllers[i].text) ?? 1.0,
),
);
final newSettings = _currentSettings.copyWith(
itemStates: newItemStates,
speechVoice: _selectedSpeechVoice?['name'] ?? '',
speechLocale: _selectedSpeechVoice?['locale'] ?? '',
localeLanguage: _selectedLocaleTag ?? '',
);
Navigator.pop(context, newSettings);
}
void _onCancel() {
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
final localizations = 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: 16, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ItemRatioSection(
localizations: localizations,
visibleItemCount: _visibleItemCount,
nameControllers: _nameControllers,
rateControllers: _rateControllers,
onIncrement: _incrementVisibleItems,
onDecrement: _decrementVisibleItems,
),
const Divider(height: 40, thickness: 1),
SwitchListTile(
title: Text(localizations.splitItem),
subtitle: Text(localizations.split),
value: _currentSettings.itemSplit,
onChanged: (bool value) {
setState(() {
_currentSettings = _currentSettings.copyWith(itemSplit: value);
});
},
),
SwitchListTile(
title: Text(localizations.fixBackgroundWhileSpinning),
value: _currentSettings.fixBackground,
onChanged: (bool value) {
setState(() {
_currentSettings = _currentSettings.copyWith(fixBackground: value);
});
},
),
const Divider(height: 40, thickness: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(localizations.maxSpeedDuration, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(localizations.maxSpeedDuration1),
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);
});
},
),
),
Text(
_currentSettings.maxSpeedDuration.toInt().toString().padLeft(2, '0'),
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 4),
],
),
),
// Place the switch outside the inner padding to align left/right with others
SwitchListTile(
title: Text(localizations.shortenRotation),
value: _currentSettings.shortenRotation,
onChanged: (bool value) {
setState(() {
_currentSettings = _currentSettings.copyWith(shortenRotation: value);
});
},
),
const Divider(height: 40, thickness: 1),
SwitchListTile(
title: Text(localizations.speechResult),
subtitle: Text(localizations.speechResult1),
value: _currentSettings.speechResult,
onChanged: (bool value) {
setState(() {
_currentSettings = _currentSettings.copyWith(speechResult: value);
});
},
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(localizations.speechVoice),
SizedBox(height: 4),
DropdownButton<Map<String, String>>(
value: _selectedSpeechVoice,
items: _speechVoices.map((Map<String, String> voice) {
return DropdownMenuItem<Map<String, String>>(
value: voice,
child: Text('${voice['locale']} - ${voice['name']}'),
);
}).toList(),
onChanged: (newValue) {
setState(() {
_selectedSpeechVoice = newValue;
});
},
),
],
),
),
const Divider(height: 40, thickness: 1),
ListTile(
title: Text(AppLocalizations.of(context)!.language),
trailing: DropdownButton<String?>(
value: _selectedLocaleTag,
hint: Text(AppLocalizations.of(context)!.systemDefault),
items: [
DropdownMenuItem<String?>(
value: null,
child: Text(AppLocalizations.of(context)!.systemDefault),
),
...languageOptions.entries.map(
(entry) => DropdownMenuItem<String?>(
value: entry.key,
child: Text(entry.value),
),
),
],
onChanged: (value) {
setState(() {
_selectedLocaleTag = value;
});
},
),
),
const Divider(height: 40, thickness: 1),
ListTile(
title: Text(AppLocalizations.of(context)!.theme),
trailing: DropdownButton<ThemeMode>(
value: _tempThemeMode,
items: [
DropdownMenuItem(
value: ThemeMode.system,
child: Text(AppLocalizations.of(context)!.systemDefault),
),
DropdownMenuItem(
value: ThemeMode.light,
child: Text(AppLocalizations.of(context)!.lightTheme),
),
DropdownMenuItem(
value: ThemeMode.dark,
child: Text(AppLocalizations.of(context)!.darkTheme),
),
],
onChanged: (value) {
if (value != null) {
setState(() {
_tempThemeMode = value;
_currentSettings = _currentSettings.copyWith(themeNumber: value.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();
},
),
],
),
),
),
);
}
}
class _ItemRatioSection extends StatelessWidget {
final AppLocalizations localizations;
final int visibleItemCount;
final List<TextEditingController> nameControllers;
final List<TextEditingController> rateControllers;
final VoidCallback onIncrement;
final VoidCallback onDecrement;
const _ItemRatioSection({
required this.localizations,
required this.visibleItemCount,
required this.nameControllers,
required this.rateControllers,
required this.onIncrement,
required this.onDecrement,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizations.itemRatio,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 10),
Table(
columnWidths: const {
0: FlexColumnWidth(0.2),
1: FlexColumnWidth(2),
2: FlexColumnWidth(1),
},
border: TableBorder.all(color: Colors.transparent),
children: List.generate(visibleItemCount, (index) {
return TableRow(
children: [
Padding(
padding: const EdgeInsets.only(top: 17.0),
child: Text((index + 1).toString()),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextField(
controller: nameControllers[index],
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
keyboardType: TextInputType.text,
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0,left: 6.0),
child: TextField(
controller: rateControllers[index],
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))],
),
),
],
);
}),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: onDecrement,
),
const SizedBox(width: 20),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: onIncrement,
),
],
),
],
);
}
}