diff --git a/mobile-app/lib/models/learn/challenge_model.dart b/mobile-app/lib/models/learn/challenge_model.dart index 01517456a..f107a3122 100644 --- a/mobile-app/lib/models/learn/challenge_model.dart +++ b/mobile-app/lib/models/learn/challenge_model.dart @@ -67,7 +67,7 @@ class Challenge { // English Challenges final FillInTheBlank? fillInTheBlank; - final EnglishAudio? audio; + final EnglishScene? audio; final Scene? scene; // Nodules for interactive challenges @@ -125,7 +125,7 @@ class Challenge { ? FillInTheBlank.fromJson(data['fillInTheBlank']) : null, audio: data['scene'] != null - ? EnglishAudio.fromJson(data['scene']['setup']['audio']) + ? EnglishScene.fromJson(data['scene']['setup']['audio']) : null, tests: (data['tests'] ?? []) .map((file) => ChallengeTest.fromJson(file)) @@ -440,11 +440,13 @@ class QuizQuestion { final String text; final List answers; final int solution; + final QuizAudioData? audioData; const QuizQuestion({ required this.text, required this.answers, required this.solution, + this.audioData, }); factory QuizQuestion.fromJson(Map data) { @@ -466,7 +468,13 @@ class QuizQuestion { allAnswers.indexWhere((a) => a.answer == data['answer']) + 1; return QuizQuestion( - text: data['text'], answers: allAnswers, solution: solutionIndex); + text: data['text'], + answers: allAnswers, + solution: solutionIndex, + audioData: data['audioData'] != null + ? QuizAudioData.fromJson(data['audioData']) + : null, + ); } } @@ -510,7 +518,7 @@ class Scene { class SceneSetup { final String background; final bool? alwaysShowDialogue; - final EnglishAudio audio; + final EnglishScene audio; final List characters; const SceneSetup({ @@ -523,7 +531,7 @@ class SceneSetup { factory SceneSetup.fromJson(Map data) { return SceneSetup( background: data['background'], - audio: EnglishAudio.fromJson(data['audio']), + audio: EnglishScene.fromJson(data['audio']), characters: data['characters'] .map( (character) => SceneCharacter.fromJson(character), @@ -626,21 +634,30 @@ class SceneDialogue { } } -class EnglishAudio { +abstract class AudioClip { + String get fileName; + String? get startTimeStamp; + String? get finishTimeStamp; +} + +class EnglishScene implements AudioClip { + @override final String fileName; final String startTime; + @override final String? startTimeStamp; + @override final String? finishTimeStamp; - const EnglishAudio({ + const EnglishScene({ required this.fileName, required this.startTime, required this.startTimeStamp, required this.finishTimeStamp, }); - factory EnglishAudio.fromJson(Map data) { - return EnglishAudio( + factory EnglishScene.fromJson(Map data) { + return EnglishScene( fileName: data['filename'], startTime: data['startTime'].toString(), startTimeStamp: data['startTimestamp']?.toString(), @@ -648,3 +665,65 @@ class EnglishAudio { ); } } + +class QuizTranscriptLine { + final String character; + final String text; + + const QuizTranscriptLine({ + required this.character, + required this.text, + }); + + factory QuizTranscriptLine.fromJson(Map data) { + return QuizTranscriptLine( + character: data['character'], + text: data['text'], + ); + } +} + +class QuizAudio implements AudioClip { + @override + final String fileName; + @override + final String? startTimeStamp; + @override + final String? finishTimeStamp; + + const QuizAudio({ + required this.fileName, + this.startTimeStamp, + this.finishTimeStamp, + }); + + factory QuizAudio.fromJson(Map data) { + return QuizAudio( + fileName: data['filename'], + startTimeStamp: data['startTimestamp']?.toString(), + finishTimeStamp: data['finishTimestamp']?.toString(), + ); + } +} + +class QuizAudioData { + final QuizAudio audio; + final List transcript; + + const QuizAudioData({ + required this.audio, + required this.transcript, + }); + + factory QuizAudioData.fromJson(Map data) { + final audioData = data['audio']; + return QuizAudioData( + audio: QuizAudio.fromJson(audioData), + transcript: (data['transcript'] as List) + .map( + (item) => QuizTranscriptLine.fromJson(item), + ) + .toList(), + ); + } +} diff --git a/mobile-app/lib/service/audio/audio_service.dart b/mobile-app/lib/service/audio/audio_service.dart index eeac96a35..42b08395b 100644 --- a/mobile-app/lib/service/audio/audio_service.dart +++ b/mobile-app/lib/service/audio/audio_service.dart @@ -239,7 +239,7 @@ class AudioPlayerHandler extends BaseAudioHandler { return 'https://cdn.freecodecamp.org/curriculum/english/animation-assets/sounds/$fileName'; } - bool canSeek(bool forward, int currentDuration, EnglishAudio audio) { + bool canSeek(bool forward, int currentDuration, AudioClip audio) { currentDuration = currentDuration + parseTimeStamp(audio.startTimeStamp).inSeconds; @@ -252,7 +252,7 @@ class AudioPlayerHandler extends BaseAudioHandler { } } - void loadEnglishAudio(EnglishAudio audio) async { + Future loadCurriculumAudio(AudioClip audio) async { await _audioPlayer.setAudioSource( ClippingAudioSource( start: parseTimeStamp(audio.startTimeStamp), @@ -260,15 +260,13 @@ class AudioPlayerHandler extends BaseAudioHandler { ? null : parseTimeStamp(audio.finishTimeStamp), child: AudioSource.uri( - Uri.parse( - returnUrl(audio.fileName), - ), + Uri.parse(returnUrl(audio.fileName)), ), ), ); await _audioPlayer.load(); setEpisodeId = ''; - _audioType = 'english'; + _audioType = 'curriculum'; } void _notifyAudioHandlerAboutPlaybackEvents() { diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart index 383a8d185..6257d85eb 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart @@ -59,6 +59,7 @@ class QuizViewModel extends BaseViewModel { text: q.text, answers: q.answers, solution: q.solution, + audioData: q.audioData, )) .toList(); } @@ -114,6 +115,7 @@ class QuizViewModel extends BaseViewModel { text: q.text, answers: q.answers, solution: q.solution, + audioData: q.audioData, )) .toList(); diff --git a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart index edd8060d6..d9a3c83b9 100644 --- a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart +++ b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart @@ -8,7 +8,7 @@ import 'package:stacked/stacked.dart'; class AudioPlayerView extends StatelessWidget { const AudioPlayerView({super.key, required this.audio}); - final EnglishAudio audio; + final EnglishScene audio; @override Widget build(BuildContext context) { @@ -16,7 +16,7 @@ class AudioPlayerView extends StatelessWidget { viewModelBuilder: () => AudioPlayerViewmodel(), onViewModelReady: (model) => { model.initPositionListener(), - model.audioService.loadEnglishAudio(audio) + model.audioService.loadCurriculumAudio(audio) }, onDispose: (model) => model.onDispose(), builder: (context, model, child) => Padding( @@ -48,7 +48,7 @@ class InnerAudioWidget extends StatelessWidget { }); final AudioPlayerViewmodel model; - final EnglishAudio audio; + final EnglishScene audio; final PlaybackState playerState; @override diff --git a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart index 1fdd82e4f..88717f7e0 100644 --- a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart @@ -17,7 +17,7 @@ class AudioPlayerViewmodel extends BaseViewModel { Duration searchTimeStamp( bool forwards, int currentPosition, - EnglishAudio audio, + EnglishScene audio, ) { if (forwards) { return Duration( diff --git a/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart b/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart new file mode 100644 index 000000000..a185975bf --- /dev/null +++ b/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart @@ -0,0 +1,231 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/models/learn/challenge_model.dart'; +import 'package:freecodecamp/service/audio/audio_service.dart'; +import 'package:freecodecamp/ui/theme/fcc_theme.dart'; +import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; + +class QuizAudioPlayer extends StatefulWidget { + const QuizAudioPlayer({super.key, required this.audioData}); + + final QuizAudioData audioData; + + @override + State createState() => _QuizAudioPlayerState(); +} + +class _QuizAudioPlayerState extends State { + final audioHandler = locator().audioHandler; + final StreamController position = + StreamController.broadcast(); + bool _isLoading = true; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _loadAudio(); + } + + Future _loadAudio() async { + try { + setState(() { + _isLoading = true; + _hasError = false; + }); + + await audioHandler.stop(); + await audioHandler.loadCurriculumAudio(widget.audioData.audio); + + // Listen to position changes + AudioService.position.listen((pos) { + if (mounted) { + position.add(pos); + } + }); + + setState(() { + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + _hasError = true; + }); + } + } + + @override + void dispose() { + position.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ); + } + + if (_hasError) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('Error loading audio'), + ), + ); + } + + return Column( + children: [ + StreamBuilder( + initialData: PlaybackState(), + stream: audioHandler.playbackState, + builder: (context, snapshot) { + final playerState = snapshot.data as PlaybackState; + + return StreamBuilder( + initialData: Duration.zero, + stream: position.stream, + builder: (context, positionSnapshot) { + if (!positionSnapshot.hasData) { + return const CircularProgressIndicator(); + } + + final currentPosition = positionSnapshot.data!; + final totalDuration = audioHandler.duration() ?? Duration.zero; + final hasZeroValue = totalDuration.inMilliseconds == 0 || + currentPosition.inMilliseconds == 0; + + return Column( + children: [ + LinearProgressIndicator( + backgroundColor: FccColors.gray75, + valueColor: const AlwaysStoppedAnimation( + FccColors.blue50, + ), + value: hasZeroValue + ? 0 + : currentPosition.inMilliseconds / + totalDuration.inMilliseconds, + minHeight: 8, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: () { + if (playerState.playing && + playerState.processingState != + AudioProcessingState.completed) { + audioHandler.pause(); + } else if (playerState.processingState == + AudioProcessingState.completed) { + audioHandler.seek(Duration.zero); + audioHandler.play(); + } else { + audioHandler.play(); + } + }, + icon: playerState.playing && + playerState.processingState != + AudioProcessingState.completed + ? const Icon(Icons.pause) + : const Icon(Icons.play_arrow), + ), + ], + ), + ], + ); + }, + ); + }, + ), + if (widget.audioData.transcript.isNotEmpty) ...[ + const SizedBox(height: 8), + _TranscriptWidget(transcript: widget.audioData.transcript), + ], + ], + ); + } +} + +class _TranscriptWidget extends StatefulWidget { + const _TranscriptWidget({required this.transcript}); + + final List transcript; + + @override + State<_TranscriptWidget> createState() => _TranscriptWidgetState(); +} + +class _TranscriptWidgetState extends State<_TranscriptWidget> { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return ChallengeCard( + title: 'Transcript', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Show transcript', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Icon( + _isExpanded ? Icons.expand_less : Icons.expand_more, + ), + ], + ), + ), + if (_isExpanded) ...[ + const SizedBox(height: 12), + ...widget.transcript.map((line) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: RichText( + text: TextSpan( + style: const TextStyle( + fontSize: 14, + color: FccColors.gray05, + ), + children: [ + TextSpan( + text: '${line.character}: ', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan(text: line.text), + ], + ), + ), + ); + }), + ], + ], + ), + ); + } +} diff --git a/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart b/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart index 891c672e7..f7f5c5235 100644 --- a/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart +++ b/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter_html/flutter_html.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; +import 'package:freecodecamp/ui/views/learn/widgets/quiz_audio_player.dart'; import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; // Model that extends Question with selectedAnswer and validation status @@ -10,6 +11,7 @@ class QuizWidgetQuestion { final String text; final List answers; final int solution; + final QuizAudioData? audioData; int selectedAnswer; bool? isCorrect; @@ -17,6 +19,7 @@ class QuizWidgetQuestion { required this.text, required this.answers, required this.solution, + this.audioData, this.selectedAnswer = -1, this.isCorrect, }); @@ -119,6 +122,10 @@ class _QuizWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ ...parsedQuestions[questionIndex], + if (question.audioData != null) ...[ + const SizedBox(height: 16), + QuizAudioPlayer(audioData: question.audioData!), + ], const SizedBox(height: 8), RadioGroup( groupValue: selectedAnswer, diff --git a/mobile-app/lib/ui/views/learn/widgets/scene/scene_view.dart b/mobile-app/lib/ui/views/learn/widgets/scene/scene_view.dart index 6e98728d8..6596dda7a 100644 --- a/mobile-app/lib/ui/views/learn/widgets/scene/scene_view.dart +++ b/mobile-app/lib/ui/views/learn/widgets/scene/scene_view.dart @@ -21,7 +21,7 @@ class SceneView extends StatelessWidget { viewModelBuilder: () => SceneViewModel(), onViewModelReady: (model) { model.initPositionListener(); - model.audioService.loadEnglishAudio(scene.setup.audio); + model.audioService.loadCurriculumAudio(scene.setup.audio); model.initScene(scene); }, onDispose: (model) => model.onDispose(), @@ -87,7 +87,8 @@ class _FullscreenSceneOverlayState extends State<_FullscreenSceneOverlay> { DeviceOrientation.landscapeRight, ]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - WidgetsBinding.instance.addPostFrameCallback((_) => _measureControlsHeight()); + WidgetsBinding.instance + .addPostFrameCallback((_) => _measureControlsHeight()); } void _measureControlsHeight() { @@ -390,14 +391,14 @@ class _CharacterSlot extends StatelessWidget { final characterHeight = canvasHeight * scale; final characterWidth = characterHeight * (2 / 3); - final xPercent = state.position.x.toDouble() / 100; + final xPercent = state.position.x.toDouble() / 100; final yPercent = state.position.y.toDouble() / 100; final leftPos = (xPercent * canvasWidth) - (characterWidth / 2); final overflowHeight = characterHeight - canvasHeight; final bottomPos = -(overflowHeight / 2) - (yPercent * canvasHeight); - return AnimatedPositioned( + return AnimatedPositioned( duration: const Duration(milliseconds: 500), curve: Curves.easeInOut, left: leftPos, diff --git a/mobile-app/lib/ui/views/learn/widgets/scene/scene_viewmodel.dart b/mobile-app/lib/ui/views/learn/widgets/scene/scene_viewmodel.dart index bc9c66827..c38c44fac 100644 --- a/mobile-app/lib/ui/views/learn/widgets/scene/scene_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/widgets/scene/scene_viewmodel.dart @@ -49,7 +49,8 @@ class CharacterState { } class SceneViewModel extends BaseViewModel { - static const String _cdnBase = 'https://cdn.freecodecamp.org/curriculum/english/animation-assets/images'; + static const String _cdnBase = + 'https://cdn.freecodecamp.org/curriculum/english/animation-assets/images'; static const int _minMouthInterval = 85; static const int _maxMouthInterval = 105; static const int _minBlinkInterval = 2000; @@ -59,7 +60,8 @@ class SceneViewModel extends BaseViewModel { final audioService = locator().audioHandler; final _sceneAssetsService = SceneAssetsService(); SceneAssets? _sceneAssets; - final StreamController position = StreamController.broadcast(); + final StreamController position = + StreamController.broadcast(); final Map _mouthAnimationTimers = {}; final Map _blinkTimers = {}; final Set _appliedCommandIndices = {}; @@ -97,7 +99,8 @@ class SceneViewModel extends BaseViewModel { // Helper methods for DRY code int _findCharacterIndex(String characterName) { - return _availableCharacters.indexWhere((c) => c.characterName == characterName); + return _availableCharacters + .indexWhere((c) => c.characterName == characterName); } void _clearTimers(Map timers) { @@ -130,13 +133,14 @@ class SceneViewModel extends BaseViewModel { await startAudio(); } - Duration searchTimeStamp(bool forwards, int currentPosition, EnglishAudio audio) { + Duration searchTimeStamp( + bool forwards, int currentPosition, EnglishScene audio) { return Duration(milliseconds: currentPosition + (forwards ? 2000 : -2)); } void initPositionListener() { AudioService.position.listen((event) { - if (position.isClosed) return; + if (position.isClosed) return; position.add(event); _updateSceneForTime(event); }); @@ -147,11 +151,12 @@ class SceneViewModel extends BaseViewModel { if (_isPlaying) { _startBlinkAnimations(); } else { - _stopAllMouthAnimations(); + _stopAllMouthAnimations(); _stopAllBlinkAnimations(); } - if (state.processingState == AudioProcessingState.completed && !_isCompleted) { + if (state.processingState == AudioProcessingState.completed && + !_isCompleted) { _handleAudioComplete(); } }); @@ -174,7 +179,8 @@ class SceneViewModel extends BaseViewModel { final command = _scene!.commands[i]; _appliedCommandIndices.add(i); - if (command.background != null && _currentBackground != command.background) { + if (command.background != null && + _currentBackground != command.background) { _currentBackground = command.background; } _updateCharacterState(command); @@ -228,7 +234,6 @@ class SceneViewModel extends BaseViewModel { showMouth: true, mouthType: 'closed', opacity: char.opacity?.toDouble() ?? 1.0, - )) .toList(); } @@ -237,7 +242,8 @@ class SceneViewModel extends BaseViewModel { if (_scene == null) return; // Apply all commands that happen before the audio starts - final audioStartTime = double.tryParse(_scene!.setup.audio.startTime) ?? 0.0; + final audioStartTime = + double.tryParse(_scene!.setup.audio.startTime) ?? 0.0; for (final command in _scene!.commands) { final startTime = command.startTime.toDouble(); @@ -256,10 +262,11 @@ class SceneViewModel extends BaseViewModel { _appliedCommandIndices.clear(); if (_scene != null) { - final startTime = double.parse(_scene!.setup.audio.startTime); + final startTime = double.tryParse(_scene!.setup.audio.startTime) ?? 0.0; _audioStartOffset = startTime; if (startTime > 0) { - await Future.delayed(Duration(milliseconds: (startTime * 1000).toInt())); + await Future.delayed( + Duration(milliseconds: (startTime * 1000).toInt())); } } @@ -275,7 +282,8 @@ class SceneViewModel extends BaseViewModel { void _updateSceneForTime(Duration currentTime) { if (_scene == null || !_isPlaying || _isCompleted) return; - final currentSeconds = (currentTime.inMilliseconds / 1000) + _audioStartOffset; + final currentSeconds = + (currentTime.inMilliseconds / 1000) + _audioStartOffset; bool sceneChanged = false; final Map charactersSpeaking = {}; @@ -304,7 +312,8 @@ class SceneViewModel extends BaseViewModel { if (currentSeconds >= startTime && !_appliedCommandIndices.contains(i)) { _appliedCommandIndices.add(i); - if (command.background != null && _currentBackground != command.background) { + if (command.background != null && + _currentBackground != command.background) { _currentBackground = command.background; sceneChanged = true; } @@ -337,7 +346,8 @@ class SceneViewModel extends BaseViewModel { if (newPosition == null && newOpacity == null) return false; - _availableCharacters[characterIndex] = _availableCharacters[characterIndex].copyWith( + _availableCharacters[characterIndex] = + _availableCharacters[characterIndex].copyWith( position: newPosition, opacity: newOpacity?.toDouble(), ); @@ -345,7 +355,8 @@ class SceneViewModel extends BaseViewModel { } else { _availableCharacters.add(CharacterState( characterName: command.character, - position: command.position ?? const SceneCharacterPosition(x: 0, y: 0, z: 0), + position: + command.position ?? const SceneCharacterPosition(x: 0, y: 0, z: 0), showMouth: true, mouthType: 'closed', opacity: command.opacity?.toDouble() ?? 1.0, @@ -370,7 +381,8 @@ class SceneViewModel extends BaseViewModel { final characterIndex = _findCharacterIndex(characterName); if (characterIndex >= 0) { - _availableCharacters[characterIndex] = _availableCharacters[characterIndex].copyWith( + _availableCharacters[characterIndex] = + _availableCharacters[characterIndex].copyWith( showMouth: true, mouthType: 'open', ); @@ -378,7 +390,8 @@ class SceneViewModel extends BaseViewModel { } void scheduleMouthUpdate(String nextMouthType) { - final interval = _minMouthInterval + random.nextInt(_maxMouthInterval - _minMouthInterval + 1); + final interval = _minMouthInterval + + random.nextInt(_maxMouthInterval - _minMouthInterval + 1); _mouthAnimationTimers[characterName] = Timer( Duration(milliseconds: interval), @@ -409,7 +422,8 @@ class SceneViewModel extends BaseViewModel { final characterIndex = _findCharacterIndex(characterName); if (characterIndex >= 0) { - _availableCharacters[characterIndex] = _availableCharacters[characterIndex].copyWith( + _availableCharacters[characterIndex] = + _availableCharacters[characterIndex].copyWith( showMouth: true, mouthType: 'closed', ); @@ -434,8 +448,8 @@ class SceneViewModel extends BaseViewModel { final random = Random(); void scheduleNextBlink() { - final interval = - _minBlinkInterval + random.nextInt(_maxBlinkInterval - _minBlinkInterval + 1); + final interval = _minBlinkInterval + + random.nextInt(_maxBlinkInterval - _minBlinkInterval + 1); _blinkTimers[characterName] = Timer( Duration(milliseconds: interval), @@ -445,7 +459,8 @@ class SceneViewModel extends BaseViewModel { if (characterIndex >= 0) { // Close eyes _availableCharacters[characterIndex] = - _availableCharacters[characterIndex].copyWith(eyeState: 'closed'); + _availableCharacters[characterIndex] + .copyWith(eyeState: 'closed'); notifyListeners(); // Open eyes after blink duration @@ -503,7 +518,9 @@ class SceneViewModel extends BaseViewModel { } String getBackgroundUrl(String backgroundName) { - final cleanName = backgroundName.endsWith('.png') ? backgroundName : '$backgroundName.png'; + final cleanName = backgroundName.endsWith('.png') + ? backgroundName + : '$backgroundName.png'; if (_sceneAssets != null) { return '${_sceneAssets!.backgrounds}/$cleanName'; } @@ -511,28 +528,32 @@ class SceneViewModel extends BaseViewModel { } String getCharacterBaseUrl(String characterName) => - _getCharacterAssets(characterName)?.base ?? _buildFallbackUrl(characterName, 'base.png'); + _getCharacterAssets(characterName)?.base ?? + _buildFallbackUrl(characterName, 'base.png'); String getCharacterBrowsUrl(String characterName) => - _getCharacterAssets(characterName)?.brows ?? _buildFallbackUrl(characterName, 'brows.png'); + _getCharacterAssets(characterName)?.brows ?? + _buildFallbackUrl(characterName, 'brows.png'); String getCharacterEyesUrl(String characterName, String eyeState) { final assets = _getCharacterAssets(characterName); if (assets != null) { return eyeState == 'closed' ? assets.eyesClosed : assets.eyesOpen; } - return _buildFallbackUrl( - characterName, eyeState == 'closed' ? 'eyes-closed.png' : 'eyes-open.png'); + return _buildFallbackUrl(characterName, + eyeState == 'closed' ? 'eyes-closed.png' : 'eyes-open.png'); } - String? getCharacterGlassesUrl(String characterName) => _getCharacterAssets(characterName)?.glasses; + String? getCharacterGlassesUrl(String characterName) => + _getCharacterAssets(characterName)?.glasses; String getCharacterMouthUrl(String characterName, String mouthType) { final assets = _getCharacterAssets(characterName); if (assets != null) { return mouthType == 'open' ? assets.mouthOpen : assets.mouthClosed; } - return _buildFallbackUrl(characterName, mouthType == 'open' ? 'mouth-open.png' : 'mouth-closed.png'); + return _buildFallbackUrl(characterName, + mouthType == 'open' ? 'mouth-open.png' : 'mouth-closed.png'); } void onDispose() {