diff --git a/lib/game/components/backbox/backbox.dart b/lib/game/components/backbox/backbox.dart index 8839a29b..887294d8 100644 --- a/lib/game/components/backbox/backbox.dart +++ b/lib/game/components/backbox/backbox.dart @@ -8,6 +8,7 @@ import 'package:pinball/game/components/backbox/displays/displays.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_theme/pinball_theme.dart' hide Assets; +import 'package:pinball_ui/pinball_ui.dart'; import 'package:share_repository/share_repository.dart'; /// {@template backbox} @@ -18,14 +19,18 @@ class Backbox extends PositionComponent with ZIndex { Backbox({ required LeaderboardRepository leaderboardRepository, required ShareRepository shareRepository, - }) : _bloc = BackboxBloc(leaderboardRepository: leaderboardRepository); + }) : _shareRepository = shareRepository, + _bloc = BackboxBloc(leaderboardRepository: leaderboardRepository); /// {@macro backbox} @visibleForTesting Backbox.test({ required BackboxBloc bloc, - }) : _bloc = bloc; + required ShareRepository shareRepository, + }) : _bloc = bloc, + _shareRepository = shareRepository; + final ShareRepository _shareRepository; late final Component _display; final BackboxBloc _bloc; late StreamSubscription _subscription; @@ -77,6 +82,18 @@ class Backbox extends PositionComponent with ZIndex { ); } else if (state is InitialsSuccessState) { _display.add(InitialsSubmissionSuccessDisplay()); + } else if (state is ShareState) { + _display.add( + ShareDisplay( + onShare: (platform) { + final url = _shareRepository.shareText( + value: state.score.toString(), + platform: platform, + ); + openLink(url); + }, + ), + ); } else if (state is InitialsFailureState) { _display.add(InitialsSubmissionFailureDisplay()); } diff --git a/lib/game/components/backbox/bloc/backbox_bloc.dart b/lib/game/components/backbox/bloc/backbox_bloc.dart index b3952a0c..5f6c7d9a 100644 --- a/lib/game/components/backbox/bloc/backbox_bloc.dart +++ b/lib/game/components/backbox/bloc/backbox_bloc.dart @@ -18,6 +18,7 @@ class BackboxBloc extends Bloc { super(LoadingState()) { on(_onPlayerInitialsRequested); on(_onPlayerInitialsSubmitted); + on(_onScoreShareRequested); on(_onLeaderboardRequested); } @@ -55,6 +56,19 @@ class BackboxBloc extends Bloc { } } + Future _onScoreShareRequested( + ShareScoreRequested event, + Emitter emit, + ) async { + emit( + ShareState( + initials: event.initials, + score: event.score, + character: event.character, + ), + ); + } + Future _onLeaderboardRequested( LeaderboardRequested event, Emitter emit, diff --git a/lib/game/components/backbox/bloc/backbox_event.dart b/lib/game/components/backbox/bloc/backbox_event.dart index 40ad4bfb..2851d75f 100644 --- a/lib/game/components/backbox/bloc/backbox_event.dart +++ b/lib/game/components/backbox/bloc/backbox_event.dart @@ -52,6 +52,30 @@ class PlayerInitialsSubmitted extends BackboxEvent { List get props => [score, initials, character]; } +/// {@template share_score_requested} +/// Event that request the user to share score and initials. +/// {@endtemplate} +class ShareScoreRequested extends BackboxEvent { + /// {@macro share_score_requested} + const ShareScoreRequested({ + required this.score, + required this.initials, + required this.character, + }); + + /// Player's score. + final int score; + + /// Player's initials. + final String initials; + + /// Player's character. + final CharacterTheme character; + + @override + List get props => [score, initials, character]; +} + /// Event that triggers the fetching of the leaderboard class LeaderboardRequested extends BackboxEvent { @override diff --git a/lib/game/components/backbox/bloc/backbox_state.dart b/lib/game/components/backbox/bloc/backbox_state.dart index 482bb298..2475b1ee 100644 --- a/lib/game/components/backbox/bloc/backbox_state.dart +++ b/lib/game/components/backbox/bloc/backbox_state.dart @@ -65,3 +65,27 @@ class InitialsFailureState extends BackboxState { @override List get props => []; } + +/// {@template share_state} +/// State when the user is sharing their score. +/// {@endtemplate} +class ShareState extends BackboxState { + /// {@macro share_state} + const ShareState({ + required this.score, + required this.initials, + required this.character, + }) : super(); + + /// Player's score. + final int score; + + /// Player's initials. + final String initials; + + /// Player's character. + final CharacterTheme character; + + @override + List get props => [score, initials, character]; +} diff --git a/test/game/components/backbox/backbox_test.dart b/test/game/components/backbox/backbox_test.dart index d61bd83a..c5c82840 100644 --- a/test/game/components/backbox/backbox_test.dart +++ b/test/game/components/backbox/backbox_test.dart @@ -19,6 +19,9 @@ import 'package:pinball/l10n/l10n.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_theme/pinball_theme.dart' as theme; +import 'package:pinball_ui/pinball_ui.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:share_repository/share_repository.dart'; class _TestGame extends Forge2DGame with HasKeyboardHandlerComponents { final character = theme.DashTheme(); @@ -33,6 +36,8 @@ class _TestGame extends Forge2DGame with HasKeyboardHandlerComponents { character.leaderboardIcon.keyName, Assets.images.backbox.marquee.keyName, Assets.images.backbox.displayDivider.keyName, + Assets.images.backbox.button.facebook.keyName, + Assets.images.backbox.button.twitter.keyName, ]); } @@ -69,6 +74,14 @@ class _MockBackboxBloc extends Mock implements BackboxBloc {} class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { } +class _MockShareRepository extends Mock implements ShareRepository {} + +class _MockTapDownInfo extends Mock implements TapDownInfo {} + +class _MockUrlLauncher extends Mock + with MockPlatformInterfaceMixin + implements UrlLauncherPlatform {} + class _MockAppLocalizations extends Mock implements AppLocalizations { @override String get score => ''; @@ -96,6 +109,15 @@ class _MockAppLocalizations extends Mock implements AppLocalizations { @override String get loading => ''; + + @override + String get letEveryone => ''; + + @override + String get bySharingYourScore => ''; + + @override + String get socialMediaAccount => ''; } void main() { @@ -118,7 +140,10 @@ void main() { flameTester.test( 'loads correctly', (game) async { - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + shareRepository: _MockShareRepository(), + ); await game.pump(backbox); expect(game.descendants(), contains(backbox)); }, @@ -127,7 +152,10 @@ void main() { flameTester.test( 'adds LeaderboardRequested when loaded', (game) async { - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + shareRepository: _MockShareRepository(), + ); await game.pump(backbox); verify(() => bloc.add(LeaderboardRequested())).called(1); @@ -142,7 +170,10 @@ void main() { ..followVector2(Vector2(0, -130)) ..zoom = 6; await game.pump( - Backbox.test(bloc: bloc), + Backbox.test( + bloc: bloc, + shareRepository: _MockShareRepository(), + ), ); await tester.pump(); }, @@ -161,6 +192,7 @@ void main() { bloc: BackboxBloc( leaderboardRepository: _MockLeaderboardRepository(), ), + shareRepository: _MockShareRepository(), ); await game.pump(backbox); backbox.requestInitials( @@ -189,7 +221,10 @@ void main() { Stream.empty(), initialState: state, ); - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + shareRepository: _MockShareRepository(), + ); await game.pump(backbox); game.onKeyEvent(_mockKeyUp(LogicalKeyboardKey.enter), {}); @@ -213,7 +248,10 @@ void main() { Stream.empty(), initialState: InitialsSuccessState(), ); - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + shareRepository: _MockShareRepository(), + ); await game.pump(backbox); expect( @@ -234,7 +272,10 @@ void main() { Stream.empty(), initialState: InitialsFailureState(), ); - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + shareRepository: _MockShareRepository(), + ); await game.pump(backbox); expect( @@ -247,6 +288,173 @@ void main() { }, ); + group('ShareDisplay', () { + late UrlLauncherPlatform urlLauncher; + + setUp(() async { + urlLauncher = _MockUrlLauncher(); + UrlLauncherPlatform.instance = urlLauncher; + }); + + flameTester.test( + 'added on ShareState', + (game) async { + final state = ShareState( + score: 100, + initials: 'AAA', + character: theme.AndroidTheme(), + ); + whenListen( + bloc, + const Stream.empty(), + initialState: state, + ); + final backbox = Backbox.test( + bloc: bloc, + shareRepository: _MockShareRepository(), + ); + await game.pump(backbox); + + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); + + flameTester.test( + 'open Facebook link when sharing with Facebook', + (game) async { + final state = ShareState( + score: 100, + initials: 'AAA', + character: theme.AndroidTheme(), + ); + whenListen( + bloc, + Stream.value(state), + initialState: state, + ); + + final shareRepository = _MockShareRepository(); + const fakeUrl = 'fakeUrl'; + when( + () => () => shareRepository.shareText( + value: any(), + platform: SharePlatform.facebook, + ), + ).thenReturn(() => fakeUrl); + when(() => urlLauncher.canLaunch(any())) + .thenAnswer((_) async => true); + when( + () => urlLauncher.launch( + any(), + useSafariVC: any(named: 'useSafariVC'), + useWebView: any(named: 'useWebView'), + enableJavaScript: any(named: 'enableJavaScript'), + enableDomStorage: any(named: 'enableDomStorage'), + universalLinksOnly: any(named: 'universalLinksOnly'), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => true); + + final backbox = Backbox.test( + bloc: bloc, + shareRepository: shareRepository, + ); + await game.pump(backbox); + + final facebookButton = + game.descendants().whereType().first; + facebookButton.onTapDown(_MockTapDownInfo()); + + verify( + () => shareRepository.shareText( + value: state.score.toString(), + platform: SharePlatform.facebook, + ), + ).called(1); + verify( + () => urlLauncher.launch( + fakeUrl, + useSafariVC: any(named: 'useSafariVC'), + useWebView: any(named: 'useWebView'), + enableJavaScript: any(named: 'enableJavaScript'), + enableDomStorage: any(named: 'enableDomStorage'), + universalLinksOnly: any(named: 'universalLinksOnly'), + headers: any(named: 'headers'), + ), + ); + }, + ); + + flameTester.test( + 'open Twitter link when sharing with Twitter', + (game) async { + final state = ShareState( + score: 100, + initials: 'AAA', + character: theme.AndroidTheme(), + ); + whenListen( + bloc, + Stream.value(state), + initialState: state, + ); + + final shareRepository = _MockShareRepository(); + const fakeUrl = 'fakeUrl'; + when( + () => () => shareRepository.shareText( + value: any(), + platform: SharePlatform.twitter, + ), + ).thenReturn(() => fakeUrl); + when(() => urlLauncher.canLaunch(any())) + .thenAnswer((_) async => true); + when( + () => urlLauncher.launch( + any(), + useSafariVC: any(named: 'useSafariVC'), + useWebView: any(named: 'useWebView'), + enableJavaScript: any(named: 'enableJavaScript'), + enableDomStorage: any(named: 'enableDomStorage'), + universalLinksOnly: any(named: 'universalLinksOnly'), + headers: any(named: 'headers'), + ), + ).thenAnswer((_) async => true); + + final backbox = Backbox.test( + bloc: bloc, + shareRepository: shareRepository, + ); + await game.pump(backbox); + + final facebookButton = + game.descendants().whereType().first; + facebookButton.onTapDown(_MockTapDownInfo()); + + verify( + () => shareRepository.shareText( + value: state.score.toString(), + platform: SharePlatform.twitter, + ), + ).called(1); + verify( + () => urlLauncher.launch( + fakeUrl, + useSafariVC: any(named: 'useSafariVC'), + useWebView: any(named: 'useWebView'), + enableJavaScript: any(named: 'enableJavaScript'), + enableDomStorage: any(named: 'enableDomStorage'), + universalLinksOnly: any(named: 'universalLinksOnly'), + headers: any(named: 'headers'), + ), + ); + }, + ); + }); + flameTester.test( 'adds LeaderboardDisplay on LeaderboardSuccessState', (game) async { @@ -256,7 +464,10 @@ void main() { initialState: LeaderboardSuccessState(entries: const []), ); - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + shareRepository: _MockShareRepository(), + ); await game.pump(backbox); expect( @@ -276,7 +487,10 @@ void main() { initialState: LoadingState(), ); - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + shareRepository: _MockShareRepository(), + ); await game.pump(backbox); backbox.removeFromParent(); diff --git a/test/game/components/backbox/bloc/backbox_bloc_test.dart b/test/game/components/backbox/bloc/backbox_bloc_test.dart index 3958adb5..5acc198a 100644 --- a/test/game/components/backbox/bloc/backbox_bloc_test.dart +++ b/test/game/components/backbox/bloc/backbox_bloc_test.dart @@ -89,6 +89,30 @@ void main() { ); }); + group('ShareScoreRequested', () { + blocTest( + 'emits ShareState', + setUp: () { + leaderboardRepository = _MockLeaderboardRepository(); + }, + build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), + act: (bloc) => bloc.add( + ShareScoreRequested( + score: 100, + initials: 'AAA', + character: AndroidTheme(), + ), + ), + expect: () => [ + ShareState( + score: 100, + initials: 'AAA', + character: AndroidTheme(), + ), + ], + ); + }); + group('LeaderboardRequested', () { blocTest( 'adds [LoadingState, LeaderboardSuccessState] when request succeeds', diff --git a/test/game/components/backbox/bloc/backbox_event_test.dart b/test/game/components/backbox/bloc/backbox_event_test.dart index 80f7fbb1..9a4f8708 100644 --- a/test/game/components/backbox/bloc/backbox_event_test.dart +++ b/test/game/components/backbox/bloc/backbox_event_test.dart @@ -123,6 +123,87 @@ void main() { }); }); + group('ScoreShareRequested', () { + test('can be instantiated', () { + expect( + ShareScoreRequested( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNotNull, + ); + }); + + test('supports value comparison', () { + expect( + ShareScoreRequested( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + equals( + ShareScoreRequested( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + ), + ); + + expect( + ShareScoreRequested( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNot( + equals( + ShareScoreRequested( + score: 1, + initials: 'AAA', + character: AndroidTheme(), + ), + ), + ), + ); + + expect( + ShareScoreRequested( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNot( + equals( + ShareScoreRequested( + score: 0, + initials: 'AAA', + character: SparkyTheme(), + ), + ), + ), + ); + + expect( + ShareScoreRequested( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNot( + equals( + ShareScoreRequested( + score: 0, + initials: 'BBB', + character: AndroidTheme(), + ), + ), + ), + ); + }); + }); + group('LeaderboardRequested', () { test('can be instantiated', () { expect(LeaderboardRequested(), isNotNull); diff --git a/test/game/components/backbox/bloc/backbox_state_test.dart b/test/game/components/backbox/bloc/backbox_state_test.dart index dd262408..85ec9bfb 100644 --- a/test/game/components/backbox/bloc/backbox_state_test.dart +++ b/test/game/components/backbox/bloc/backbox_state_test.dart @@ -132,5 +132,35 @@ void main() { }); }); }); + + group('ShareState', () { + test('can be instantiated', () { + expect( + ShareState( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNotNull, + ); + }); + + test('supports value comparison', () { + expect( + ShareState( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + equals( + ShareState( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + ), + ); + }); + }); }); }