diff --git a/assets/images/select_character/pinball_button.png b/assets/images/select_character/pinball_button.png new file mode 100644 index 00000000..62373b85 Binary files /dev/null and b/assets/images/select_character/pinball_button.png differ diff --git a/assets/images/select_character/star_a.png b/assets/images/select_character/star_a.png new file mode 100644 index 00000000..81d42de9 Binary files /dev/null and b/assets/images/select_character/star_a.png differ diff --git a/assets/images/select_character/star_b.png b/assets/images/select_character/star_b.png new file mode 100644 index 00000000..2832de06 Binary files /dev/null and b/assets/images/select_character/star_b.png differ diff --git a/assets/images/select_character/star_c.png b/assets/images/select_character/star_c.png new file mode 100644 index 00000000..9e3135a8 Binary files /dev/null and b/assets/images/select_character/star_c.png differ diff --git a/lib/game/view/widgets/pinball_button.dart b/lib/game/view/widgets/pinball_button.dart new file mode 100644 index 00000000..db388ef1 --- /dev/null +++ b/lib/game/view/widgets/pinball_button.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:pinball/gen/gen.dart'; + +// TODO(arturplaczek): move PinballButton to pinball_ui + +/// {@template pinball_button} +/// Pinball button with onPressed [VoidCallback] and child [Widget]. +/// {@endtemplate} +class PinballButton extends StatelessWidget { + /// {@macro pinball_button} + const PinballButton({ + Key? key, + required Widget child, + VoidCallback? onPressed, + }) : _child = child, + _onPressed = onPressed, + super(key: key); + + final Widget _child; + final VoidCallback? _onPressed; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: _onPressed, + child: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage( + Assets.images.selectCharacter.pinballButton.keyName, + ), + ), + ), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + child: _child, + ), + ), + ), + ); + } +} diff --git a/lib/game/view/widgets/widgets.dart b/lib/game/view/widgets/widgets.dart index 5d1fccf8..2bc78c9b 100644 --- a/lib/game/view/widgets/widgets.dart +++ b/lib/game/view/widgets/widgets.dart @@ -1,5 +1,6 @@ export 'bonus_animation.dart'; export 'game_hud.dart'; +export 'pinball_button.dart'; export 'play_button_overlay.dart'; export 'round_count_display.dart'; export 'score_view.dart'; diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index f5b935a5..2cc31a63 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -15,6 +15,8 @@ class $AssetsImagesGen { $AssetsImagesComponentsGen get components => const $AssetsImagesComponentsGen(); $AssetsImagesScoreGen get score => const $AssetsImagesScoreGen(); + $AssetsImagesSelectCharacterGen get selectCharacter => + const $AssetsImagesSelectCharacterGen(); } class $AssetsImagesBonusAnimationGen { @@ -57,6 +59,26 @@ class $AssetsImagesScoreGen { const AssetGenImage('assets/images/score/mini_score_background.png'); } +class $AssetsImagesSelectCharacterGen { + const $AssetsImagesSelectCharacterGen(); + + /// File path: assets/images/select_character/pinball_button.png + AssetGenImage get pinballButton => + const AssetGenImage('assets/images/select_character/pinball_button.png'); + + /// File path: assets/images/select_character/star_a.png + AssetGenImage get starA => + const AssetGenImage('assets/images/select_character/star_a.png'); + + /// File path: assets/images/select_character/star_b.png + AssetGenImage get starB => + const AssetGenImage('assets/images/select_character/star_b.png'); + + /// File path: assets/images/select_character/star_c.png + AssetGenImage get starC => + const AssetGenImage('assets/images/select_character/star_c.png'); +} + class Assets { Assets._(); diff --git a/lib/select_character/select_character.dart b/lib/select_character/select_character.dart index 40699840..827be100 100644 --- a/lib/select_character/select_character.dart +++ b/lib/select_character/select_character.dart @@ -1,2 +1,3 @@ export 'cubit/character_theme_cubit.dart'; export 'view/view.dart'; +export 'widgets/widgets.dart'; diff --git a/lib/select_character/widgets/character_icon.dart b/lib/select_character/widgets/character_icon.dart new file mode 100644 index 00000000..3da77903 --- /dev/null +++ b/lib/select_character/widgets/character_icon.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball_theme/pinball_theme.dart' hide Assets; + +/// {@template character_icon} +/// Widget to display character icon. +/// +/// On tap changes selected character in [CharacterThemeCubit]. +/// {@endtemplate} +class CharacterIcon extends StatelessWidget { + /// {@macro character_icon} + const CharacterIcon( + CharacterTheme characterTheme, { + Key? key, + }) : _characterTheme = characterTheme, + super(key: key); + + final CharacterTheme _characterTheme; + + @override + Widget build(BuildContext context) { + final currentCharacterTheme = + context.select( + (cubit) => cubit.state.characterTheme, + ); + + return GestureDetector( + onTap: () => context + .read() + .characterSelected(_characterTheme), + child: Opacity( + opacity: currentCharacterTheme == _characterTheme ? 1 : 0.5, + child: Padding( + padding: const EdgeInsets.all(8), + child: _characterTheme.icon.image( + fit: BoxFit.contain, + ), + ), + ), + ); + } +} diff --git a/lib/select_character/widgets/selected_character.dart b/lib/select_character/widgets/selected_character.dart new file mode 100644 index 00000000..0b6fc758 --- /dev/null +++ b/lib/select_character/widgets/selected_character.dart @@ -0,0 +1,105 @@ +import 'package:flame/flame.dart'; +import 'package:flame/sprite.dart'; +import 'package:flutter/material.dart' hide Image; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/theme/theme.dart'; +import 'package:pinball_flame/pinball_flame.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +/// {@template selected_character} +/// Widget to display the selected character based on the [CharacterThemeCubit] +/// state. +/// +/// Displays the looped [SpriteAnimationWidget] and the character name on the +/// list. +/// {@endtemplate} +class SelectedCharacter extends StatefulWidget { + /// {@macro selected_character} + const SelectedCharacter({ + Key? key, + }) : super(key: key); + + @override + State createState() => _SelectedCharacterState(); + + /// Returns a list of assets to be loaded. + static List loadAssets() { + Flame.images.prefix = ''; + + const dashTheme = DashTheme(); + const androidTheme = AndroidTheme(); + const dinoTheme = DinoTheme(); + const sparkyTheme = SparkyTheme(); + + return [ + Flame.images.load(dashTheme.animation.keyName), + Flame.images.load(androidTheme.animation.keyName), + Flame.images.load(dinoTheme.animation.keyName), + Flame.images.load(sparkyTheme.animation.keyName), + Flame.images.load(dashTheme.background.keyName), + Flame.images.load(androidTheme.background.keyName), + Flame.images.load(dinoTheme.background.keyName), + Flame.images.load(sparkyTheme.background.keyName), + ]; + } +} + +class _SelectedCharacterState extends State + with TickerProviderStateMixin { + late SpriteAnimationController _controller; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final currentCharacter = + context.select( + (cubit) => cubit.state.characterTheme, + ); + final spriteSheet = SpriteSheet.fromColumnsAndRows( + image: Flame.images.fromCache(currentCharacter.animation.keyName), + columns: 12, + rows: 6, + ); + final animation = spriteSheet.createAnimation( + row: 0, + stepTime: 1 / 24, + to: spriteSheet.rows * spriteSheet.columns, + ); + + _controller = SpriteAnimationController( + vsync: this, + animation: animation, + ); + + _controller + ..forward() + ..repeat(); + + return LayoutBuilder( + builder: (context, constraints) { + return ListView( + children: [ + Text( + currentCharacter.name, + style: AppTextStyle.headline3, + ), + const SizedBox(height: 20), + SizedBox( + width: constraints.maxWidth, + height: constraints.maxWidth, + child: SpriteAnimationWidget( + controller: _controller, + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/select_character/widgets/star_animation.dart b/lib/select_character/widgets/star_animation.dart new file mode 100644 index 00000000..62b0de4a --- /dev/null +++ b/lib/select_character/widgets/star_animation.dart @@ -0,0 +1,95 @@ +import 'package:flame/flame.dart'; +import 'package:flame/sprite.dart'; +import 'package:flame/widgets.dart'; +import 'package:flutter/material.dart' hide Image; +import 'package:pinball/gen/gen.dart'; + +/// {@template star_animation} +/// Widget to display a looped the star animation. +/// +/// For animation uses [SpriteAnimationWidget]. +/// {@endtemplate} +class StarAnimation extends StatelessWidget { + const StarAnimation._({ + Key? key, + required String imagePath, + required int columns, + required int rows, + required double stepTime, + }) : _imagePath = imagePath, + _columns = columns, + _rows = rows, + _stepTime = stepTime, + super(key: key); + + /// [Widget] that displays the star A animation. + StarAnimation.starA({ + Key? key, + }) : this._( + key: key, + imagePath: Assets.images.selectCharacter.starA.keyName, + columns: 36, + rows: 2, + stepTime: 1 / 18, + ); + + /// [Widget] that displays the star B animation. + StarAnimation.starB({ + Key? key, + }) : this._( + key: key, + imagePath: Assets.images.selectCharacter.starB.keyName, + columns: 36, + rows: 2, + stepTime: 1 / 36, + ); + + /// [Widget] that displays the star C animation. + StarAnimation.starC({ + Key? key, + }) : this._( + key: key, + imagePath: Assets.images.selectCharacter.starC.keyName, + columns: 72, + rows: 1, + stepTime: 1 / 24, + ); + + final String _imagePath; + final int _columns; + final int _rows; + final double _stepTime; + + /// Returns a list of assets to be loaded. + static Future loadAssets() { + Flame.images.prefix = ''; + + return Flame.images.loadAll([ + Assets.images.selectCharacter.starA.keyName, + Assets.images.selectCharacter.starB.keyName, + Assets.images.selectCharacter.starC.keyName, + ]); + } + + @override + Widget build(BuildContext context) { + final spriteSheet = SpriteSheet.fromColumnsAndRows( + image: Flame.images.fromCache(_imagePath), + columns: _columns, + rows: _rows, + ); + final animation = spriteSheet.createAnimation( + row: 0, + stepTime: _stepTime, + to: spriteSheet.rows * spriteSheet.columns, + ); + + return SizedBox( + width: 30, + height: 30, + child: SpriteAnimationWidget( + animation: animation, + ), + ); + } +} diff --git a/lib/select_character/widgets/widgets.dart b/lib/select_character/widgets/widgets.dart new file mode 100644 index 00000000..91f40a86 --- /dev/null +++ b/lib/select_character/widgets/widgets.dart @@ -0,0 +1,3 @@ +export 'character_icon.dart'; +export 'selected_character.dart'; +export 'star_animation.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index f129ea19..94a50281 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ flutter: - assets/images/components/ - assets/images/bonus_animation/ - assets/images/score/ + - assets/images/select_character/ flutter_gen: line_length: 80 diff --git a/test/game/view/widgets/pinball_button_test.dart b/test/game/view/widgets/pinball_button_test.dart new file mode 100644 index 00000000..50566dde --- /dev/null +++ b/test/game/view/widgets/pinball_button_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/game.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + const buttonText = 'this is the button text'; + + testWidgets('displays button', (tester) async { + await tester.pumpApp( + const Material( + child: PinballButton( + child: Text(buttonText), + ), + ), + ); + + expect(find.text(buttonText), findsOneWidget); + }); + + testWidgets('on tap calls onPressed callback', (tester) async { + var isTapped = false; + + await tester.pumpApp( + Material( + child: PinballButton( + child: const Text(buttonText), + onPressed: () { + isTapped = true; + }, + ), + ), + ); + await tester.tap(find.text(buttonText)); + + expect(isTapped, isTrue); + }); +} diff --git a/test/select_character/widgets/character_icon_test.dart b/test/select_character/widgets/character_icon_test.dart new file mode 100644 index 00000000..60dabba6 --- /dev/null +++ b/test/select_character/widgets/character_icon_test.dart @@ -0,0 +1,51 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + late CharacterThemeCubit characterThemeCubit; + + group('CharacterIcon', () { + setUp(() { + characterThemeCubit = MockCharacterThemeCubit(); + + whenListen( + characterThemeCubit, + const Stream.empty(), + initialState: const CharacterThemeState.initial(), + ); + }); + + testWidgets('renders character icon', (tester) async { + const characterTheme = DashTheme(); + + await tester.pumpApp( + const CharacterIcon(characterTheme), + characterThemeCubit: characterThemeCubit, + ); + + expect(find.image(characterTheme.icon), findsOneWidget); + }); + + testWidgets('tap on icon calls characterSelected on cubit', (tester) async { + const characterTheme = DashTheme(); + + await tester.pumpApp( + CharacterIcon(characterTheme), + characterThemeCubit: characterThemeCubit, + ); + + await tester.tap(find.byType(CharacterIcon)); + + verify( + () => characterThemeCubit.characterSelected(characterTheme), + ).called(1); + }); + }); +} diff --git a/test/select_character/widgets/selected_character_test.dart b/test/select_character/widgets/selected_character_test.dart new file mode 100644 index 00000000..70ccf3fc --- /dev/null +++ b/test/select_character/widgets/selected_character_test.dart @@ -0,0 +1,47 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame/flame.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball_flame/pinball_flame.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late CharacterThemeCubit characterThemeCubit; + + setUpAll(() async { + Flame.images.prefix = ''; + await Flame.images.load(const DashTheme().animation.keyName); + }); + + setUp(() async { + characterThemeCubit = MockCharacterThemeCubit(); + + whenListen( + characterThemeCubit, + Stream.value(const CharacterThemeState.initial()), + initialState: const CharacterThemeState.initial(), + ); + }); + + group('SelectedCharacter', () { + testWidgets('loadAssets method returns list of futures', (tester) async { + expect(SelectedCharacter.loadAssets(), isList); + }); + + testWidgets('renders selected character', (tester) async { + await tester.pumpApp( + SelectedCharacter(), + characterThemeCubit: characterThemeCubit, + ); + await tester.pump(); + + expect(find.byType(SpriteAnimationWidget), findsOneWidget); + }); + }); +} diff --git a/test/select_character/widgets/star_animation_test.dart b/test/select_character/widgets/star_animation_test.dart new file mode 100644 index 00000000..a743407f --- /dev/null +++ b/test/select_character/widgets/star_animation_test.dart @@ -0,0 +1,44 @@ +// ignore_for_file: invalid_use_of_protected_member + +import 'package:flame/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/select_character/select_character.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('loads SpriteAnimationWidget correctly for', () { + setUpAll(() async { + await StarAnimation.loadAssets(); + }); + + testWidgets('starA', (tester) async { + await tester.pumpApp( + StarAnimation.starA(), + ); + await tester.pump(); + + expect(find.byType(SpriteAnimationWidget), findsOneWidget); + }); + + testWidgets('starB', (tester) async { + await tester.pumpApp( + StarAnimation.starB(), + ); + await tester.pump(); + + expect(find.byType(SpriteAnimationWidget), findsOneWidget); + }); + + testWidgets('starC', (tester) async { + await tester.pumpApp( + StarAnimation.starC(), + ); + await tester.pump(); + + expect(find.byType(SpriteAnimationWidget), findsOneWidget); + }); + }); +}