pubspec.yaml
name: fortuneslip
description: "FortuneSlip"
# 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.2+18
environment:
sdk: ^3.9.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
shared_preferences: ^2.3.2
flutter_tts: ^4.0.2
google_mobile_ads: ^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:
# 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
generate: true
assets:
- assets/image/
- assets/image/fortune/
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# 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:fortuneslip/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 Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: bannerAd.size.width.toDouble(),
height: bannerAd.size.height.toDouble(),
child: AdWidget(ad: bannerAd),
),
],
)
]
);
} else {
return const SizedBox.shrink();
}
},
),
);
}
}
lib/ad_manager.dart
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:ui';
import 'package: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/fortune_data.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class FortuneItem {
const FortuneItem({required this.rawName, required this.ratio});
final String rawName;
final int ratio;
String get display {
final index = rawName.indexOf(':');
if (index == -1) {
return rawName.trim();
}
return rawName.substring(0, index).trim();
}
String get reading {
final index = rawName.indexOf(':');
if (index == -1) {
return rawName.trim();
}
return rawName.substring(index + 1).trim();
}
FortuneItem copyWith({String? rawName, int? ratio}) {
return FortuneItem(
rawName: rawName ?? this.rawName,
ratio: ratio ?? this.ratio,
);
}
}
class FortuneSettings {
FortuneSettings({
required List<FortuneItem> items,
required this.speakResult,
required this.animationSpeed,
required this.countdownTime,
required this.themeMode,
required this.locale,
this.speechVoice = '',
this.speechLocale = '',
}) : items = List<FortuneItem>.from(items);
final List<FortuneItem> items;
final bool speakResult;
final int animationSpeed;
final int countdownTime;
final ThemeMode themeMode;
final Locale? locale;
final String speechVoice;
final String speechLocale;
FortuneSettings copyWith({
List<FortuneItem>? items,
bool? speakResult,
int? animationSpeed,
int? countdownTime,
ThemeMode? themeMode,
Locale? locale,
String? speechVoice,
String? speechLocale,
}) {
return FortuneSettings(
items: items ?? this.items,
speakResult: speakResult ?? this.speakResult,
animationSpeed: animationSpeed ?? this.animationSpeed,
countdownTime: countdownTime ?? this.countdownTime,
themeMode: themeMode ?? this.themeMode,
locale: locale ?? this.locale,
speechVoice: speechVoice ?? this.speechVoice,
speechLocale: speechLocale ?? this.speechLocale,
);
}
}
class FortuneDefaults {
static List<FortuneItem> forLocale(Locale? locale) {
final code = locale?.languageCode;
if (code == 'ja') {
return _jaDefaults;
}
return _enDefaults;
}
static List<FortuneItem> get _enDefaults => const [
FortuneItem(rawName: 'Great blessing', ratio: 1),
FortuneItem(rawName: 'Blessing', ratio: 3),
FortuneItem(rawName: 'Middle blessing', ratio: 5),
FortuneItem(rawName: 'Small blessing', ratio: 5),
FortuneItem(rawName: 'Uncertain luck', ratio: 5),
FortuneItem(rawName: 'Curse', ratio: 1),
FortuneItem(rawName: 'Great curse', ratio: 1),
];
static List<FortuneItem> get _jaDefaults => const [
FortuneItem(rawName: '大吉:だいきち', ratio: 1),
FortuneItem(rawName: '吉:きち', ratio: 3),
FortuneItem(rawName: '中吉:ちゅうきち', ratio: 5),
FortuneItem(rawName: '小吉:しょうきち', ratio: 5),
FortuneItem(rawName: '末吉:すえきち', ratio: 5),
FortuneItem(rawName: '凶:きょう', ratio: 1),
FortuneItem(rawName: '大凶:だいきょう', ratio: 1),
];
}
class FortuneStorage {
static const _speechNumberKey = 'speechNumber';
static const _speechVoiceKey = 'speechVoice';
static const _speechLocaleKey = 'speechLocale';
static const _animationSpeedKey = 'animationSpeed';
static const _countdownTimeKey = 'countdownTime';
static const _themeNumberKey = 'themeNumber';
static const _localeLanguageKey = 'localeLanguage';
Future<FortuneSettings> load() async {
final prefs = await SharedPreferences.getInstance();
final localeCode = prefs.getString(_localeLanguageKey) ?? '';
final locale = localeCode.isEmpty ? null : Locale(localeCode);
final items = <FortuneItem>[];
for (var i = 1; i <= 7; i++) {
final name = prefs.getString('itemName$i') ?? '';
final ratio = prefs.getInt('itemRatio$i') ?? 0;
items.add(FortuneItem(rawName: name, ratio: max(0, ratio)));
}
final allNamesEmpty = items.every((item) => item.rawName.isEmpty);
final effectiveItems = allNamesEmpty
? FortuneDefaults.forLocale(locale ?? WidgetsBinding.instance.platformDispatcher.locale)
: items;
final speakResult = (prefs.getInt(_speechNumberKey) ?? 1) != 0;
final speechVoice = prefs.getString(_speechVoiceKey) ?? '';
final speechLocale = prefs.getString(_speechLocaleKey) ?? '';
final animationSpeed = 4000;
final countdownTime = (prefs.getInt(_countdownTimeKey) ?? 3).clamp(0, 9);
final themeValue = prefs.getInt(_themeNumberKey);
final themeMode = switch (themeValue) {
0 => ThemeMode.light,
1 => ThemeMode.dark,
2 => ThemeMode.system,
null => ThemeMode.system,
_ => ThemeMode.system,
};
return FortuneSettings(
items: effectiveItems,
speakResult: speakResult,
animationSpeed: animationSpeed,
countdownTime: countdownTime,
themeMode: themeMode,
locale: locale,
speechVoice: speechVoice,
speechLocale: speechLocale,
);
}
Future<void> save(FortuneSettings settings) async {
final prefs = await SharedPreferences.getInstance();
for (var i = 0; i < settings.items.length; i++) {
final item = settings.items[i];
final index = i + 1;
await prefs.setString('itemName$index', item.rawName);
await prefs.setInt('itemRatio$index', item.ratio);
}
await prefs.setInt(_speechNumberKey, settings.speakResult ? 1 : 0);
if (settings.speechVoice.isEmpty && settings.speechLocale.isEmpty) {
await prefs.remove(_speechVoiceKey);
await prefs.remove(_speechLocaleKey);
} else {
await prefs.setString(_speechVoiceKey, settings.speechVoice);
await prefs.setString(_speechLocaleKey, settings.speechLocale);
}
await prefs.setInt(_animationSpeedKey, 4000);
await prefs.setInt(_countdownTimeKey, settings.countdownTime.clamp(0, 9));
final themeValue = switch (settings.themeMode) {
ThemeMode.light => 0,
ThemeMode.dark => 1,
ThemeMode.system => 2,
};
await prefs.setInt(_themeNumberKey, themeValue);
if (settings.locale == null) {
await prefs.remove(_localeLanguageKey);
} else {
await prefs.setString(_localeLanguageKey, settings.locale!.languageCode);
}
}
}
lib/main.dart
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:fortuneslip/fortune_data.dart';
import 'package:fortuneslip/l10n/app_localizations.dart';
import 'package:fortuneslip/settings_page.dart';
import 'package:fortuneslip/ad_manager.dart';
import 'package:fortuneslip/ad_banner_widget.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final storage = FortuneStorage();
final settings = await storage.load();
runApp(FortuneSlipApp(storage: storage, initialSettings: settings));
}
class FortuneSlipApp extends StatefulWidget {
const FortuneSlipApp({
super.key,
required this.storage,
required this.initialSettings,
});
final FortuneStorage storage;
final FortuneSettings initialSettings;
@override
State<FortuneSlipApp> createState() => _FortuneSlipAppState();
}
class _FortuneSlipAppState extends State<FortuneSlipApp> {
late FortuneSettings _settings;
@override
void initState() {
super.initState();
_settings = widget.initialSettings;
}
void _updateSettings(FortuneSettings newSettings) {
setState(() {
_settings = newSettings;
});
unawaited(widget.storage.save(newSettings));
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Fortune Slip',
theme: _buildLightTheme(),
darkTheme: _buildDarkTheme(),
themeMode: _settings.themeMode,
locale: _settings.locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: FortuneHomePage(
settings: _settings,
onSettingsChanged: _updateSettings,
),
);
}
}
ThemeData _buildLightTheme() {
const primary = Color(0xFFFE0000);
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: ColorScheme.fromSeed(seedColor: primary).copyWith(
primary: primary,
secondary: const Color(0xFFFF8181),
surface: const Color(0xFFFFFFFF),
onPrimary: Colors.white,
onSecondary: Colors.white,
),
scaffoldBackgroundColor: primary,
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFFFE0000),
foregroundColor: Colors.white,
elevation: 0,
),
textTheme: const TextTheme(bodyMedium: TextStyle(color: Colors.white)),
);
}
ThemeData _buildDarkTheme() {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: const ColorScheme.dark().copyWith(
primary: const Color(0xFFFE0000),
secondary: const Color(0xFFFF8A65),
),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFFFE0000),
foregroundColor: Colors.white,
elevation: 0,
),
);
}
class FortuneHomePage extends StatefulWidget {
const FortuneHomePage({
super.key,
required this.settings,
required this.onSettingsChanged,
});
final FortuneSettings settings;
final ValueChanged<FortuneSettings> onSettingsChanged;
@override
State<FortuneHomePage> createState() => _FortuneHomePageState();
}
class _FortuneHomePageState extends State<FortuneHomePage> {
late AdManager _adManager;
static const int _frameCount = 120;
static const List<_TextSpec> _textSpecs = [
_TextSpec(frame: 84, x: 547, y: 440),
_TextSpec(frame: 85, x: 549, y: 444),
_TextSpec(frame: 86, x: 550, y: 450),
_TextSpec(frame: 87, x: 550, y: 454),
_TextSpec(frame: 88, x: 552, y: 459),
_TextSpec(frame: 89, x: 554, y: 464),
_TextSpec(frame: 90, x: 555, y: 469),
_TextSpec(frame: 91, x: 556, y: 474),
_TextSpec(frame: 92, x: 558, y: 479),
_TextSpec(frame: 93, x: 560, y: 484),
_TextSpec(frame: 94, x: 562, y: 489),
_TextSpec(frame: 95, x: 563, y: 495),
_TextSpec(frame: 96, x: 565, y: 500),
_TextSpec(frame: 97, x: 567, y: 505),
_TextSpec(frame: 98, x: 569, y: 511),
_TextSpec(frame: 99, x: 571, y: 516),
_TextSpec(frame: 100, x: 573, y: 522),
_TextSpec(frame: 101, x: 575, y: 530),
_TextSpec(frame: 102, x: 578, y: 539),
_TextSpec(frame: 103, x: 580, y: 550),
_TextSpec(frame: 104, x: 583, y: 563),
_TextSpec(frame: 105, x: 587, y: 576),
_TextSpec(frame: 106, x: 590, y: 592),
_TextSpec(frame: 107, x: 594, y: 608),
_TextSpec(frame: 108, x: 599, y: 626),
_TextSpec(frame: 109, x: 604, y: 645),
_TextSpec(frame: 110, x: 608, y: 665),
_TextSpec(frame: 111, x: 614, y: 687),
_TextSpec(frame: 112, x: 619, y: 708),
_TextSpec(frame: 113, x: 625, y: 730),
_TextSpec(frame: 114, x: 630, y: 751),
_TextSpec(frame: 115, x: 636, y: 772),
_TextSpec(frame: 116, x: 641, y: 792),
_TextSpec(frame: 117, x: 646, y: 808),
_TextSpec(frame: 118, x: 651, y: 822),
_TextSpec(frame: 119, x: 654, y: 832),
];
static const int _countdownFramesPerDigit = 30;
static const Duration _countdownFrameInterval = Duration(milliseconds: 30);
static const Duration _ticketFrameDuration = Duration(milliseconds: 40);
late FortuneSettings _settings;
final Random _random = Random();
final FlutterTts _flutterTts = FlutterTts();
Timer? _animationTimer;
Timer? _countdownTimer;
int _currentFrame = 0;
int? _countdown;
String? _countdownAsset;
double _countdownScale = 1.1;
double _countdownOpacity = 0;
int _countdownFrames = 0;
int _countdownValue = 0;
List<ui.Image> _decodedFrames = [];
Future<void>? _frameLoadFuture;
bool _framesReady = false;
Future<void>? _countdownPrecacheFuture;
FortuneItem? _activeFortune;
bool _isAnimating = false;
bool get _isBusy => _isAnimating || _countdown != null;
@override
void initState() {
super.initState();
_settings = widget.settings;
_adManager = AdManager();
_configureTts();
unawaited(_flutterTts.setSpeechRate(0.5));
unawaited(_flutterTts.awaitSpeakCompletion(true));
_frameLoadFuture = _loadAnimationFrames();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_countdownPrecacheFuture ??= _precacheCountdownAssets(context);
}
@override
void didUpdateWidget(covariant FortuneHomePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.settings != widget.settings) {
_settings = widget.settings;
_configureTts();
setState(() {});
}
}
@override
void dispose() {
_adManager.dispose();
_animationTimer?.cancel();
_countdownTimer?.cancel();
unawaited(_flutterTts.stop());
for (final image in _decodedFrames) {
image.dispose();
}
_decodedFrames = [];
super.dispose();
}
Future<void> _configureTts() async {
final voiceName = _settings.speechVoice;
final voiceLocale = _settings.speechLocale;
try {
if (voiceLocale.isNotEmpty) {
await _flutterTts.setLanguage(voiceLocale);
}
if (voiceName.isNotEmpty && voiceLocale.isNotEmpty) {
await _flutterTts.setVoice({'name': voiceName, 'locale': voiceLocale});
return;
}
if (voiceLocale.isNotEmpty) {
return;
}
} catch (_) {
// Ignore failures and fall back to locale based language.
}
final localeCode = _settings.locale?.languageCode ??
WidgetsBinding.instance.platformDispatcher.locale.languageCode;
final fallback = localeCode == 'ja' ? 'ja-JP' : 'en-US';
try {
await _flutterTts.setLanguage(fallback);
} catch (_) {
// Best effort; ignore errors from the platform TTS engine.
}
}
void _handleTap() {
if (_isBusy) {
return;
}
final selected = _selectFortune();
if (selected == null) {
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.empty)),
);
return;
}
setState(() {
_activeFortune = selected;
_currentFrame = 0;
_isAnimating = false;
});
final int countdownTarget = _settings.countdownTime.clamp(0, 9);
if (countdownTarget <= 0) {
setState(() {
_countdown = null;
_countdownAsset = null;
_countdownOpacity = 0;
});
unawaited(_startAnimation());
return;
}
setState(() {
_countdownValue = countdownTarget;
_countdown = _countdownValue;
_countdownFrames = _countdownFramesPerDigit;
_countdownAsset = _countdownAssetFor(_countdownValue);
_countdownScale = 1.1;
_countdownOpacity = 0;
});
_countdownTimer?.cancel();
_countdownTimer = Timer.periodic(_countdownFrameInterval, (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
_countdownFrames -= 1;
if (_countdownFrames <= 0) {
_countdownValue -= 1;
if (_countdownValue <= 0) {
_countdown = null;
_countdownAsset = null;
_countdownOpacity = 0;
timer.cancel();
unawaited(_startAnimation());
return;
}
_countdown = _countdownValue;
_countdownFrames = _countdownFramesPerDigit;
_countdownAsset = _countdownAssetFor(_countdownValue);
}
final frame = _countdownFrames.toDouble();
_countdownScale = 1 + 0.1 * (frame / _countdownFramesPerDigit);
if (frame >= 20) {
_countdownOpacity = (_countdownFramesPerDigit - frame) / 10;
} else if (frame <= 5) {
_countdownOpacity = frame / 5;
} else {
_countdownOpacity = 1;
}
if (_countdownOpacity < 0) {
_countdownOpacity = 0;
} else if (_countdownOpacity > 1) {
_countdownOpacity = 1;
}
});
});
}
Future<void> _precacheCountdownAssets(BuildContext context) async {
final ctx = context;
final futures = <Future<void>>[];
for (var i = 1; i <= 9; i++) {
// ignore: use_build_context_synchronously
futures.add(precacheImage(AssetImage(_countdownAssetFor(i)), ctx));
}
// ignore: use_build_context_synchronously
futures.add(
precacheImage(const AssetImage('assets/image/number_null.webp'), ctx),
);
try {
await Future.wait(futures);
} catch (_) {
// Ignore errors; countdown images will fall back to on-demand decoding.
}
}
Future<void> _loadAnimationFrames() async {
final frames = <ui.Image>[];
try {
for (var i = 0; i < _frameCount; i++) {
final data = await rootBundle.load(_frameAsset(i));
final codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
try {
final frame = await codec.getNextFrame();
frames.add(frame.image);
} finally {
codec.dispose();
}
}
if (!mounted) {
for (final image in frames) {
image.dispose();
}
return;
}
setState(() {
_decodedFrames = frames;
_framesReady = true;
});
} catch (_) {
for (final image in frames) {
image.dispose();
}
if (mounted) {
setState(() {
_decodedFrames = [];
_framesReady = true;
});
} else {
_framesReady = true;
}
}
}
Future<void> _ensureFramesReady() async {
if (_framesReady) {
return;
}
final future = _frameLoadFuture;
if (future != null) {
try {
await future;
} catch (_) {
// Ignore errors and fall back to asset-based rendering.
}
}
}
Future<void> _startAnimation() async {
_animationTimer?.cancel();
await _ensureFramesReady();
if (!mounted) {
return;
}
setState(() {
_isAnimating = true;
_currentFrame = 0;
});
_animationTimer = Timer.periodic(_ticketFrameDuration, (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
if (_currentFrame >= _frameCount - 1) {
timer.cancel();
_isAnimating = false;
_currentFrame = _frameCount - 1;
_speakResult();
} else {
_currentFrame += 1;
}
});
});
}
FortuneItem? _selectFortune() {
final candidates = _settings.items
.where((item) => item.ratio > 0 && item.display.isNotEmpty)
.toList();
final total = candidates.fold<int>(0, (value, item) => value + item.ratio);
if (total == 0) {
return null;
}
var remain = _random.nextInt(total);
for (final item in candidates) {
remain -= item.ratio;
if (remain < 0) {
return item;
}
}
return candidates.isNotEmpty ? candidates.last : null;
}
Future<void> _speakResult() async {
if (!_settings.speakResult || _activeFortune == null) {
return;
}
await _flutterTts.stop();
final text = _activeFortune!.reading;
if (text.isEmpty) {
return;
}
await _flutterTts.speak(text);
}
Future<void> _openSettings() async {
if (_isBusy) {
return;
}
final result = await Navigator.of(context).push<FortuneSettings>(
MaterialPageRoute(
builder: (context) => SettingsPage(settings: _settings),
),
);
if (result != null) {
widget.onSettingsChanged(result);
}
}
@override
Widget build(BuildContext context) {
final localization = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(localization.appTitle),
actions: [
TextButton(
onPressed: _isBusy ? null : _openSettings,
child: Text(
localization.setting,
style: TextStyle(
color: _isBusy
? Colors.white.withValues(alpha: 0.4)
: Colors.white,
),
),
),
],
),
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleTap,
child: Container(
color: const Color(0xFFFE0000),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 12),
child: SizedBox(
width: double.infinity,
child: Text(
localization.tapToDraw,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color.fromRGBO(255, 255, 255, 0.5),
fontSize: 14,
),
),
),
),
Expanded(
child: Stack(
children: [
Positioned.fill(child: _buildDrawingArea()),
if (_countdownAsset != null) _buildCountdownOverlay(),
],
),
),
],
),
),
),
),
bottomNavigationBar: Container(
color: Color(0xFFFE0000),
child: AdBannerWidget(adManager: _adManager),
)
);
}
Widget _buildDrawingArea() {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight;
final boxSize = min(width, height);
final marginLeft = (width - boxSize) / 2;
final marginTop = (height - boxSize) / 2;
final textLayout = _layoutForFrame(
_currentFrame,
boxSize,
marginLeft,
marginTop,
);
final imageAsset = _frameAsset(_currentFrame);
final ui.Image? frameImage = _decodedFrames.isEmpty
? null
: _decodedFrames[_currentFrame.clamp(0, _decodedFrames.length - 1)];
return Stack(
children: [
Positioned(
left: marginLeft,
top: marginTop,
width: boxSize,
height: boxSize,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: frameImage != null
? CustomPaint(
painter: _FramePainter(frameImage),
child: const SizedBox.expand(),
)
: Image.asset(
imageAsset,
fit: BoxFit.contain,
key: ValueKey<int>(_currentFrame),
gaplessPlayback: true,
),
),
),
),
if (textLayout != null && _activeFortune != null)
Positioned(
left: textLayout.offset.dx,
top: textLayout.offset.dy,
child: Opacity(
opacity: _currentFrame >= 84 ? 1 : 0,
child: Text(
_activeFortune!.display,
style: TextStyle(
fontSize: textLayout.fontSize,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
},
);
}
Widget _buildCountdownOverlay() {
final asset = _countdownAsset;
if (asset == null) {
return const SizedBox.shrink();
}
return Positioned.fill(
child: Container(
color: Colors.transparent,
child: Center(
child: Opacity(
opacity: _countdownOpacity.clamp(0.0, 1.0),
child: Transform.scale(
scale: _countdownScale,
child: Image.asset(asset),
),
),
),
),
);
}
_TextLayout? _layoutForFrame(
int frame,
double boxSize,
double marginLeft,
double marginTop,
) {
if (frame < 84) {
return null;
}
final spec = _textSpecs.firstWhere(
(element) => element.frame == frame,
orElse: () => const _TextSpec(frame: -1, x: 0, y: 0),
);
if (spec.frame == -1) {
return null;
}
final boxPixel = boxSize / 900.0;
final dx = marginLeft + boxPixel * spec.x - boxPixel * ((frame - 84) + 36);
final dy =
marginTop + boxPixel * spec.y - boxPixel * ((frame - 84) * 1.3 + 24);
final fontSize = ((frame - 84) / (119 - 84)) * 14 + 4;
return _TextLayout(offset: Offset(dx, dy), fontSize: fontSize);
}
String _frameAsset(int frame) {
final clamped = frame.clamp(0, 119) + 1;
final padded = clamped.toString().padLeft(3, '0');
return 'assets/image/fortune/omikuji$padded.jpg';
}
String _countdownAssetFor(int value) {
var clamped = value;
if (clamped <= 0) {
return 'assets/image/number_null.webp';
}
if (clamped > 9) {
clamped = 9;
}
return 'assets/image/number${clamped.toString()}.webp';
}
}
class _FramePainter extends CustomPainter {
const _FramePainter(this.image);
final ui.Image image;
@override
void paint(Canvas canvas, Size size) {
final paint = ui.Paint()..filterQuality = ui.FilterQuality.high;
final imageWidth = image.width.toDouble();
final imageHeight = image.height.toDouble();
if (imageWidth == 0 ||
imageHeight == 0 ||
size.width == 0 ||
size.height == 0) {
return;
}
final imageAspect = imageWidth / imageHeight;
final canvasAspect = size.width / size.height;
Rect dst;
if (imageAspect > canvasAspect) {
final drawHeight = size.width / imageAspect;
final dy = (size.height - drawHeight) / 2.0;
dst = Rect.fromLTWH(0, dy, size.width, drawHeight);
} else {
final drawWidth = size.height * imageAspect;
final dx = (size.width - drawWidth) / 2.0;
dst = Rect.fromLTWH(dx, 0, drawWidth, size.height);
}
final src = Rect.fromLTWH(0, 0, imageWidth, imageHeight);
canvas.drawImageRect(image, src, dst, paint);
}
@override
bool shouldRepaint(covariant _FramePainter oldDelegate) {
return oldDelegate.image != image;
}
}
class _TextSpec {
const _TextSpec({required this.frame, required this.x, required this.y});
final int frame;
final double x;
final double y;
}
class _TextLayout {
const _TextLayout({required this.offset, required this.fontSize});
final Offset offset;
final double fontSize;
}
lib/settings_page.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:fortuneslip/fortune_data.dart';
import 'package:fortuneslip/l10n/app_localizations.dart';
import 'package:fortuneslip/ad_manager.dart';
import 'package:fortuneslip/ad_banner_widget.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key, required this.settings});
final FortuneSettings settings;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
late AdManager _adManager;
late List<TextEditingController> _nameControllers;
late List<TextEditingController> _ratioControllers;
late bool _speakResult;
late int _themeSelection;
late String _languageCode;
late int _countdownTime;
List<_VoiceOption> _voiceOptions = const [];
bool _voicesLoading = false;
String _selectedVoiceId = '';
@override
void initState() {
super.initState();
_adManager = AdManager();
_nameControllers = widget.settings.items
.map((item) => TextEditingController(text: item.rawName))
.toList();
_ratioControllers = widget.settings.items
.map(
(item) => TextEditingController(
text: item.ratio > 0 ? item.ratio.toString() : '',
),
)
.toList();
_speakResult = widget.settings.speakResult;
_themeSelection = _themeModeToValue(widget.settings.themeMode);
_languageCode = widget.settings.locale?.languageCode ?? '';
_countdownTime = widget.settings.countdownTime;
_selectedVoiceId =
widget.settings.speechVoice.isNotEmpty &&
widget.settings.speechLocale.isNotEmpty
? _VoiceOption(
widget.settings.speechLocale,
widget.settings.speechVoice,
).id
: '';
_voicesLoading = true;
_loadVoices();
}
Future<void> _loadVoices() async {
try {
final tts = FlutterTts();
final voices = await tts.getVoices;
final optionsById = <String, _VoiceOption>{};
if (voices is List) {
for (final voice in voices) {
if (voice is Map) {
final name = voice['name'];
final locale = voice['locale'];
if (name is String && locale is String) {
final option = _VoiceOption(locale, name);
optionsById.putIfAbsent(option.id, () => option);
}
}
}
}
final options = optionsById.values.toList()
..sort((a, b) => a.label.compareTo(b.label));
if (!mounted) {
return;
}
setState(() {
_voiceOptions = options;
if (_selectedVoiceId.isNotEmpty &&
options.every((option) => option.id != _selectedVoiceId)) {
_selectedVoiceId = '';
}
_voicesLoading = false;
});
} catch (_) {
if (!mounted) {
return;
}
setState(() {
_voiceOptions = const [];
_selectedVoiceId = '';
_voicesLoading = false;
});
}
}
@override
void dispose() {
_adManager.dispose();
for (final controller in _nameControllers) {
controller.dispose();
}
for (final controller in _ratioControllers) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final localization = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final backColor = theme.brightness == Brightness.light
? Colors.white
: Colors.black;
final foreColor = theme.brightness == Brightness.light
? Colors.grey[800]
: Colors.grey[300];
const accentColor = Color(0xFF8C1A1A);
final themed = theme.copyWith(
colorScheme: theme.colorScheme.copyWith(
primary: accentColor,
secondary: accentColor,
tertiary: accentColor,
),
sliderTheme: theme.sliderTheme.copyWith(
activeTrackColor: accentColor,
inactiveTrackColor: accentColor.withValues(alpha: 0.3),
thumbColor: accentColor,
overlayColor: accentColor.withValues(alpha: 0.12),
),
);
return Theme(
data: themed,
child: Scaffold(
backgroundColor: backColor,
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.close),
tooltip: localization.cancel,
onPressed: () => Navigator.of(context).pop(),
style: IconButton.styleFrom(foregroundColor: foreColor),
),
actions: <Widget>[
Padding(
padding: const EdgeInsets.only(right: 10),
child: IconButton(
icon: const Icon(Icons.check),
tooltip: localization.apply,
onPressed: _apply,
style: IconButton.styleFrom(foregroundColor: foreColor),
),
),
],
),
body: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
_buildFortuneTable(localization),
const SizedBox(height: 8),
Text(
localization.fortunesHint,
style: Theme.of(context).textTheme.bodySmall,
),
const Divider(height: 40),
_buildCountdownSlider(localization),
const Divider(height: 40),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(localization.speakResult),
value: _speakResult,
activeThumbColor: accentColor,
activeTrackColor: accentColor.withValues(
alpha: theme.brightness == Brightness.dark ? 0.6 : 0.5,
),
onChanged: (value) {
setState(() {
_speakResult = value;
});
},
),
const SizedBox(height: 8),
_buildVoiceSelector(localization, accentColor),
const Divider(height: 40),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(localization.theme),
trailing: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _themeSelection,
iconEnabledColor: accentColor,
iconDisabledColor: accentColor.withValues(alpha: 0.4),
items: [
DropdownMenuItem(
value: 0,
child: Text(localization.themeSystem),
),
DropdownMenuItem(
value: 1,
child: Text(localization.lightTheme),
),
DropdownMenuItem(
value: 2,
child: Text(localization.darkTheme),
),
],
onChanged: (value) {
if (value == null) {
return;
}
setState(() {
_themeSelection = value;
});
},
),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(localization.language),
trailing: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _languageCode,
iconEnabledColor: accentColor,
iconDisabledColor: accentColor.withValues(alpha: 0.4),
items: [
DropdownMenuItem(
value: '',
child: Text(localization.languageSystem),
),
DropdownMenuItem(
value: 'en',
child: Text(localization.languageEn),
),
DropdownMenuItem(
value: 'ja',
child: Text(localization.languageJa),
),
],
onChanged: (value) {
setState(() {
_languageCode = value ?? '';
});
},
),
),
),
const Divider(height: 40),
const SizedBox(height: 100),
],
),
),
),
bottomNavigationBar: AdBannerWidget(adManager: _adManager),
),
);
}
Widget _buildVoiceSelector(AppLocalizations localization, Color accentColor) {
final dropdownItems = <DropdownMenuItem<String>>[
DropdownMenuItem(value: '', child: Text(localization.voiceDefault)),
..._voiceOptions.map(
(option) => DropdownMenuItem<String>(
value: option.id,
child: Text(option.label),
),
),
];
final hasSelectedVoice = _voiceOptions.any(
(option) => option.id == _selectedVoiceId,
);
final value = hasSelectedVoice ? _selectedVoiceId : '';
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(localization.voice),
subtitle: _voicesLoading
? Text(localization.voiceLoading)
: (_voiceOptions.isEmpty ? Text(localization.voiceDefault) : null),
trailing: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: value,
iconEnabledColor: accentColor,
iconDisabledColor: accentColor.withValues(alpha: 0.4),
items: dropdownItems,
onChanged: _voicesLoading
? null
: (selected) {
if (selected == null) {
return;
}
setState(() {
_selectedVoiceId = selected;
});
},
),
),
);
}
Widget _buildFortuneTable(AppLocalizations localization) {
final headerStyle = Theme.of(context).textTheme.titleMedium;
final foreColor = Theme.of(context).brightness == Brightness.light
? Colors.grey[800]
: Colors.grey[300];
return Table(
columnWidths: const {
0: IntrinsicColumnWidth(),
1: FlexColumnWidth(),
2: IntrinsicColumnWidth(),
3: IntrinsicColumnWidth(),
},
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
children: [
TableRow(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(localization.fortuneLabel, style: headerStyle),
),
const SizedBox(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(localization.ratioLabel, style: headerStyle),
),
const SizedBox(),
],
),
for (var i = 0; i < _nameControllers.length; i++)
TableRow(
children: [
Text('${i + 1}', style: TextStyle(color: foreColor)),
Padding(
padding: const EdgeInsets.only(left: 0, right: 4, bottom: 4),
child: TextField(
controller: _nameControllers[i],
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 0, bottom: 4),
child: SizedBox(
width: 80,
child: TextField(
controller: _ratioControllers[i],
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
),
),
),
),
const SizedBox.shrink(),
],
),
],
);
}
Widget _buildCountdownSlider(AppLocalizations localization) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(localization.countdownTime, style: theme.textTheme.titleMedium),
Row(
children: [
Expanded(
flex: 1,
child: Slider(
value: _countdownTime.toDouble(),
min: 0,
max: 9,
divisions: 9,
label: _countdownTime.toString(),
onChanged: (value) {
setState(() {
_countdownTime = value.round();
});
},
),
),
SizedBox(
width: 20,
child: Text(
_countdownTime.toString(),
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
],
),
],
);
}
void _apply() {
final locale = _languageCode.isEmpty ? null : Locale(_languageCode);
final parts = _selectedVoiceId.split('|');
String speechLocale = '';
String speechVoice = '';
if (parts.length == 2) {
speechLocale = parts[0];
speechVoice = parts[1];
}
final items = List<FortuneItem>.generate(_nameControllers.length, (index) {
final name = _nameControllers[index].text.trim();
final ratioText = _ratioControllers[index].text.trim();
final ratio = int.tryParse(ratioText) ?? 0;
return FortuneItem(rawName: name, ratio: max(0, ratio));
});
final allEmpty = items.every((item) => item.rawName.isEmpty);
final normalizedItems = allEmpty
? FortuneDefaults.forLocale(locale)
: items;
final updated = widget.settings.copyWith(
items: normalizedItems,
speakResult: _speakResult,
countdownTime: _countdownTime,
themeMode: _valueToThemeMode(_themeSelection),
locale: locale,
speechVoice: speechVoice,
speechLocale: speechLocale,
);
Navigator.of(context).pop(updated);
}
int _themeModeToValue(ThemeMode mode) {
switch (mode) {
case ThemeMode.system:
return 0;
case ThemeMode.light:
return 1;
case ThemeMode.dark:
return 2;
}
}
ThemeMode _valueToThemeMode(int value) {
switch (value) {
case 0:
return ThemeMode.system;
case 1:
return ThemeMode.light;
case 2:
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
}
class _VoiceOption {
const _VoiceOption(this.locale, this.name);
final String locale;
final String name;
String get id => '$locale|$name';
String get label => '$locale $name';
}