feat(audio): looping per-season background music system
MusicService (looping audioplayers player, independent of SFX) driven by the active season theme's new 'bgm' key; switches track on season change, pauses on app background, all failures swallowed. Separate Music on/off toggle in Settings (persisted, independent of SFX). Season packs carry bgm keys (menu/season_001/ season_002), manifest regenerated. Assets slot assets/audio/bgm/ ready — drop in menu.mp3/season_001.mp3/season_002.mp3 (CC0) and it plays; silent until then. 180 tests green, analyze clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
Drop background-music tracks here as MP3, named by theme key:
|
||||||
|
menu.mp3 — home/menu (SeasonTheme.fallback, bgm="menu")
|
||||||
|
season_001.mp3 — Season 1 "First Bloom"
|
||||||
|
season_002.mp3 — Season 2 "Summer Tide"
|
||||||
|
|
||||||
|
Use CC0 / royalty-free, commercial-safe tracks (see docs). The app plays
|
||||||
|
whatever is present and stays silent (no error) for any missing track.
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"tileSet": "spring",
|
"tileSet": "spring",
|
||||||
"background": "background.webp"
|
"background": "background.webp",
|
||||||
|
"bgm": "season_001"
|
||||||
},
|
},
|
||||||
"stages": [
|
"stages": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
"seasonId": "season_001",
|
"seasonId": "season_001",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"packUrl": "seasons/season_001/pack.json",
|
"packUrl": "seasons/season_001/pack.json",
|
||||||
"sha256": "5b20b88251931838563aaaa7729f48e5a35f09dbf80c576b9bc2ec944050fc0a"
|
"sha256": "6018fc20187e5835e1a5bc5a3626479dcd448e3a9f35c708b89722587a881468"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"seasonId": "season_002",
|
"seasonId": "season_002",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"packUrl": "seasons/season_002/pack.json",
|
"packUrl": "seasons/season_002/pack.json",
|
||||||
"sha256": "47cc115f9982ade7df686b28aa95a82edcc1e8a4aae5f13319e7131477855de3"
|
"sha256": "7be1d0082d9fa81b25938c340801baf9cc0deecdbbd0cdc2d75af443e9fb8552"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"tileSet": "spring",
|
"tileSet": "spring",
|
||||||
"background": "background.webp"
|
"background": "background.webp",
|
||||||
|
"bgm": "season_001"
|
||||||
},
|
},
|
||||||
"stages": [
|
"stages": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
4280179302
|
4280179302
|
||||||
],
|
],
|
||||||
"accentColor": 4285517301,
|
"accentColor": 4285517301,
|
||||||
"particleType": "petals"
|
"particleType": "petals",
|
||||||
|
"bgm": "season_002"
|
||||||
},
|
},
|
||||||
"stages": [
|
"stages": [
|
||||||
{
|
{
|
||||||
|
|||||||
+24
-1
@@ -13,21 +13,44 @@ class BlockSeasonsApp extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<BlockSeasonsApp> createState() => _BlockSeasonsAppState();
|
ConsumerState<BlockSeasonsApp> createState() => _BlockSeasonsAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BlockSeasonsAppState extends ConsumerState<BlockSeasonsApp> {
|
class _BlockSeasonsAppState extends ConsumerState<BlockSeasonsApp>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
ref.read(consentServiceProvider).ensureConsentAndInitialize();
|
ref.read(consentServiceProvider).ensureConsentAndInitialize();
|
||||||
// Eagerly start the IAP service so its purchase stream is live for the
|
// Eagerly start the IAP service so its purchase stream is live for the
|
||||||
// whole session — restores and interrupted/deferred transactions are
|
// whole session — restores and interrupted/deferred transactions are
|
||||||
// delivered (and completed) even if the player never opens Settings.
|
// delivered (and completed) even if the player never opens Settings.
|
||||||
ref.read(iapServiceProvider);
|
ref.read(iapServiceProvider);
|
||||||
|
// Start background music for the current context (menu by default).
|
||||||
|
ref.read(musicServiceProvider).playKey(ref.read(activeThemeProvider).bgm);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// Pause music while the app is not in the foreground.
|
||||||
|
ref
|
||||||
|
.read(musicServiceProvider)
|
||||||
|
.setBackgrounded(state != AppLifecycleState.resumed);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Switch the looping track whenever the active season theme changes
|
||||||
|
// (home/menu -> a season -> back). playKey is a no-op if unchanged.
|
||||||
|
ref.listen(activeThemeProvider, (_, next) {
|
||||||
|
ref.read(musicServiceProvider).playKey(next.bgm);
|
||||||
|
});
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
|
onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ class SaveRepository {
|
|||||||
_soundEnabled =
|
_soundEnabled =
|
||||||
(json['flags'] as Map<String, dynamic>?)?['soundEnabled'] as bool? ??
|
(json['flags'] as Map<String, dynamic>?)?['soundEnabled'] as bool? ??
|
||||||
true;
|
true;
|
||||||
|
_musicEnabled =
|
||||||
|
(json['flags'] as Map<String, dynamic>?)?['musicEnabled'] as bool? ??
|
||||||
|
true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +63,14 @@ class SaveRepository {
|
|||||||
int _endlessBest = 0;
|
int _endlessBest = 0;
|
||||||
bool _adsRemoved = false;
|
bool _adsRemoved = false;
|
||||||
bool _soundEnabled = true;
|
bool _soundEnabled = true;
|
||||||
|
bool _musicEnabled = true;
|
||||||
|
|
||||||
StreakState get streak => _streak;
|
StreakState get streak => _streak;
|
||||||
bool get tutorialDone => _tutorialDone;
|
bool get tutorialDone => _tutorialDone;
|
||||||
int get endlessBest => _endlessBest;
|
int get endlessBest => _endlessBest;
|
||||||
bool get adsRemoved => _adsRemoved;
|
bool get adsRemoved => _adsRemoved;
|
||||||
bool get soundEnabled => _soundEnabled;
|
bool get soundEnabled => _soundEnabled;
|
||||||
|
bool get musicEnabled => _musicEnabled;
|
||||||
|
|
||||||
Future<void> markTutorialDone() {
|
Future<void> markTutorialDone() {
|
||||||
_tutorialDone = true;
|
_tutorialDone = true;
|
||||||
@@ -82,6 +87,11 @@ class SaveRepository {
|
|||||||
return _flush();
|
return _flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setMusicEnabled(bool value) {
|
||||||
|
_musicEnabled = value;
|
||||||
|
return _flush();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> recordEndlessScore(int score) {
|
Future<void> recordEndlessScore(int score) {
|
||||||
if (score > _endlessBest) _endlessBest = score;
|
if (score > _endlessBest) _endlessBest = score;
|
||||||
return _flush();
|
return _flush();
|
||||||
@@ -154,6 +164,7 @@ class SaveRepository {
|
|||||||
'tutorialDone': _tutorialDone,
|
'tutorialDone': _tutorialDone,
|
||||||
'adsRemoved': _adsRemoved,
|
'adsRemoved': _adsRemoved,
|
||||||
'soundEnabled': _soundEnabled,
|
'soundEnabled': _soundEnabled,
|
||||||
|
'musicEnabled': _musicEnabled,
|
||||||
},
|
},
|
||||||
'endless': {'best': _endlessBest},
|
'endless': {'best': _endlessBest},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class SeasonTheme {
|
|||||||
this.particleType = 'petals',
|
this.particleType = 'petals',
|
||||||
this.tilePalette,
|
this.tilePalette,
|
||||||
this.boardTint,
|
this.boardTint,
|
||||||
|
this.bgm = 'menu',
|
||||||
});
|
});
|
||||||
|
|
||||||
factory SeasonTheme.fromJson(Map<String, dynamic> json) => SeasonTheme(
|
factory SeasonTheme.fromJson(Map<String, dynamic> json) => SeasonTheme(
|
||||||
@@ -25,6 +26,7 @@ class SeasonTheme {
|
|||||||
? [for (final c in json['tilePalette'] as List) (c as num).toInt()]
|
? [for (final c in json['tilePalette'] as List) (c as num).toInt()]
|
||||||
: null,
|
: null,
|
||||||
boardTint: json['boardTint'] as int?,
|
boardTint: json['boardTint'] as int?,
|
||||||
|
bgm: (json['bgm'] as String?) ?? 'menu',
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Season 1 "First Bloom": deep navy dusk.
|
/// Season 1 "First Bloom": deep navy dusk.
|
||||||
@@ -48,6 +50,10 @@ class SeasonTheme {
|
|||||||
/// Optional board background override.
|
/// Optional board background override.
|
||||||
final int? boardTint;
|
final int? boardTint;
|
||||||
|
|
||||||
|
/// Looping background-music track key, resolved to `assets/audio/bgm/KEY.mp3`.
|
||||||
|
/// Remote seasons reuse bundled track keys (no remote audio download).
|
||||||
|
final String bgm;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'tileSet': tileSet,
|
'tileSet': tileSet,
|
||||||
'background': background,
|
'background': background,
|
||||||
@@ -56,6 +62,7 @@ class SeasonTheme {
|
|||||||
'particleType': particleType,
|
'particleType': particleType,
|
||||||
if (tilePalette != null) 'tilePalette': tilePalette,
|
if (tilePalette != null) 'tilePalette': tilePalette,
|
||||||
if (boardTint != null) 'boardTint': boardTint,
|
if (boardTint != null) 'boardTint': boardTint,
|
||||||
|
'bgm': bgm,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -60,5 +60,6 @@
|
|||||||
"restorePurchases": "Restore purchases",
|
"restorePurchases": "Restore purchases",
|
||||||
"adsRemovedThanks": "Ads removed — thank you!",
|
"adsRemovedThanks": "Ads removed — thank you!",
|
||||||
"purchaseUnavailable": "Purchases are unavailable right now.",
|
"purchaseUnavailable": "Purchases are unavailable right now.",
|
||||||
"soundAndVibration": "Sound & vibration"
|
"soundAndVibration": "Sound & vibration",
|
||||||
|
"music": "Music"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -32,5 +32,6 @@
|
|||||||
"restorePurchases": "구매 복원",
|
"restorePurchases": "구매 복원",
|
||||||
"adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
|
"adsRemovedThanks": "광고가 제거되었습니다 — 감사합니다!",
|
||||||
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.",
|
"purchaseUnavailable": "지금은 구매를 사용할 수 없습니다.",
|
||||||
"soundAndVibration": "소리 및 진동"
|
"soundAndVibration": "소리 및 진동",
|
||||||
|
"music": "음악"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
|
|
||||||
|
/// Looping background music, independent of the SFX pool in [AudioService].
|
||||||
|
/// One track plays at a time from `assets/audio/bgm/<key>.mp3`. A track change
|
||||||
|
/// (e.g. switching seasons) restarts; toggling sound off or backgrounding the
|
||||||
|
/// app pauses/resumes the same track. Every player call is swallowed — music
|
||||||
|
/// must never break the app, and a missing track asset is silently ignored.
|
||||||
|
class MusicService {
|
||||||
|
MusicService({bool enabled = true}) : _enabled = enabled;
|
||||||
|
|
||||||
|
/// Background music sits well under the SFX so it never competes.
|
||||||
|
static const double _volume = 0.45;
|
||||||
|
|
||||||
|
final AudioPlayer _player = AudioPlayer();
|
||||||
|
bool _enabled;
|
||||||
|
bool _backgrounded = false;
|
||||||
|
String? _currentKey;
|
||||||
|
|
||||||
|
bool get enabled => _enabled;
|
||||||
|
bool get _shouldPlay => _enabled && !_backgrounded && _currentKey != null;
|
||||||
|
|
||||||
|
/// Request a looping track by theme key (e.g. 'menu', 'season_001'). No-op
|
||||||
|
/// if it is already the current track (avoids restarting on every rebuild).
|
||||||
|
Future<void> playKey(String key) async {
|
||||||
|
if (key == _currentKey) {
|
||||||
|
await _reconcile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_currentKey = key;
|
||||||
|
await _restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
set enabled(bool value) {
|
||||||
|
if (_enabled == value) return;
|
||||||
|
_enabled = value;
|
||||||
|
_reconcile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App lifecycle: pause when backgrounded, resume on return.
|
||||||
|
void setBackgrounded(bool value) {
|
||||||
|
if (_backgrounded == value) return;
|
||||||
|
_backgrounded = value;
|
||||||
|
_reconcile();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restart() async {
|
||||||
|
try {
|
||||||
|
await _player.stop();
|
||||||
|
if (!_shouldPlay) return;
|
||||||
|
await _player.setReleaseMode(ReleaseMode.loop);
|
||||||
|
await _player.play(
|
||||||
|
AssetSource('audio/bgm/$_currentKey.mp3'),
|
||||||
|
volume: _volume,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Missing asset or platform hiccup: stay silent.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause/resume the CURRENT track to match [_shouldPlay] without restarting.
|
||||||
|
Future<void> _reconcile() async {
|
||||||
|
try {
|
||||||
|
if (_shouldPlay) {
|
||||||
|
if (_player.state == PlayerState.paused) {
|
||||||
|
await _player.resume();
|
||||||
|
} else if (_player.state != PlayerState.playing) {
|
||||||
|
await _restart();
|
||||||
|
}
|
||||||
|
} else if (_player.state == PlayerState.playing) {
|
||||||
|
await _player.pause();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Never throw from lifecycle/toggle changes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() => _player.dispose();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'providers.dart';
|
||||||
|
|
||||||
|
/// Background-music on/off, seeded from the save repository. Independent of the
|
||||||
|
/// SFX/haptics ([soundEnabledProvider]) so players can keep one without the
|
||||||
|
/// other — the common "I want sound effects but no music" case.
|
||||||
|
class MusicEnabledNotifier extends Notifier<bool> {
|
||||||
|
@override
|
||||||
|
bool build() => ref.read(saveRepositoryProvider).musicEnabled;
|
||||||
|
|
||||||
|
Future<void> toggle() => set(!state);
|
||||||
|
|
||||||
|
Future<void> set(bool value) async {
|
||||||
|
if (state == value) return;
|
||||||
|
await ref.read(saveRepositoryProvider).setMusicEnabled(value);
|
||||||
|
state = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@ import '../services/analytics_service.dart';
|
|||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
import '../services/consent_service.dart';
|
import '../services/consent_service.dart';
|
||||||
import '../services/iap_service.dart';
|
import '../services/iap_service.dart';
|
||||||
|
import '../services/music_service.dart';
|
||||||
import 'ads_notifier.dart';
|
import 'ads_notifier.dart';
|
||||||
import 'endless_best_notifier.dart';
|
import 'endless_best_notifier.dart';
|
||||||
|
import 'music_notifier.dart';
|
||||||
import 'sound_notifier.dart';
|
import 'sound_notifier.dart';
|
||||||
import 'game_session_notifier.dart';
|
import 'game_session_notifier.dart';
|
||||||
import 'progress_notifier.dart';
|
import 'progress_notifier.dart';
|
||||||
@@ -33,6 +35,16 @@ final audioServiceProvider = Provider<AudioService>((ref) {
|
|||||||
return service;
|
return service;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final musicEnabledProvider =
|
||||||
|
NotifierProvider<MusicEnabledNotifier, bool>(MusicEnabledNotifier.new);
|
||||||
|
|
||||||
|
final musicServiceProvider = Provider<MusicService>((ref) {
|
||||||
|
final service = MusicService(enabled: ref.read(musicEnabledProvider));
|
||||||
|
ref.listen<bool>(musicEnabledProvider, (_, next) => service.enabled = next);
|
||||||
|
ref.onDispose(service.dispose);
|
||||||
|
return service;
|
||||||
|
});
|
||||||
|
|
||||||
/// Overridden with the opened repository in main() (and in tests).
|
/// Overridden with the opened repository in main() (and in tests).
|
||||||
final saveRepositoryProvider = Provider<SaveRepository>(
|
final saveRepositoryProvider = Provider<SaveRepository>(
|
||||||
(ref) => throw UnimplementedError('override with an opened SaveRepository'),
|
(ref) => throw UnimplementedError('override with an opened SaveRepository'),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final adsRemoved = ref.watch(adsRemovedProvider);
|
final adsRemoved = ref.watch(adsRemovedProvider);
|
||||||
final soundOn = ref.watch(soundEnabledProvider);
|
final soundOn = ref.watch(soundEnabledProvider);
|
||||||
|
final musicOn = ref.watch(musicEnabledProvider);
|
||||||
final iap = ref.read(iapServiceProvider);
|
final iap = ref.read(iapServiceProvider);
|
||||||
|
|
||||||
ref.listen<bool>(adsRemovedProvider, (prev, next) {
|
ref.listen<bool>(adsRemovedProvider, (prev, next) {
|
||||||
@@ -44,6 +45,12 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
ref.read(soundEnabledProvider.notifier).set(v),
|
ref.read(soundEnabledProvider.notifier).set(v),
|
||||||
),
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(l10n.music),
|
||||||
|
value: musicOn,
|
||||||
|
onChanged: (v) =>
|
||||||
|
ref.read(musicEnabledProvider.notifier).set(v),
|
||||||
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(l10n.removeAds),
|
title: Text(l10n.removeAds),
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ flutter:
|
|||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/audio/
|
- assets/audio/
|
||||||
|
- assets/audio/bgm/
|
||||||
- assets/seasons/season_001/
|
- assets/seasons/season_001/
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:block_seasons/data/save_repository.dart';
|
||||||
|
import 'package:block_seasons/game/models/season.dart';
|
||||||
|
import 'package:block_seasons/state/providers.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('musicEnabled persistence', () {
|
||||||
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||||
|
|
||||||
|
test('defaults true and persists across reopen', () async {
|
||||||
|
final repo = await SaveRepository.open();
|
||||||
|
expect(repo.musicEnabled, isTrue);
|
||||||
|
await repo.setMusicEnabled(false);
|
||||||
|
final reopened = await SaveRepository.open();
|
||||||
|
expect(reopened.musicEnabled, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy save without the flag reads as true', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'save_v1': '{"saveVersion":1,"progress":{},"flags":{"tutorialDone":true}}',
|
||||||
|
});
|
||||||
|
final repo = await SaveRepository.open();
|
||||||
|
expect(repo.musicEnabled, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('musicEnabledProvider toggles and persists', () async {
|
||||||
|
final repo = await SaveRepository.open();
|
||||||
|
final c = ProviderContainer(
|
||||||
|
overrides: [saveRepositoryProvider.overrideWithValue(repo)],
|
||||||
|
);
|
||||||
|
addTearDown(c.dispose);
|
||||||
|
expect(c.read(musicEnabledProvider), isTrue);
|
||||||
|
await c.read(musicEnabledProvider.notifier).toggle();
|
||||||
|
expect(c.read(musicEnabledProvider), isFalse);
|
||||||
|
expect(repo.musicEnabled, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SeasonTheme.bgm', () {
|
||||||
|
test('defaults to menu and round-trips through json', () {
|
||||||
|
expect(const SeasonTheme().bgm, 'menu');
|
||||||
|
final theme = SeasonTheme.fromJson(const {'bgm': 'season_001'});
|
||||||
|
expect(theme.bgm, 'season_001');
|
||||||
|
expect(SeasonTheme.fromJson(theme.toJson()).bgm, 'season_001');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user