diff --git a/lib/home/bloc/home_bloc.dart b/lib/home/bloc/home_bloc.dart index 6847878..0792903 100644 --- a/lib/home/bloc/home_bloc.dart +++ b/lib/home/bloc/home_bloc.dart @@ -14,7 +14,9 @@ class HomeBloc extends Bloc { on(_onQuestion); on(_queryUpdated); on(_questionAsked); + on(_onResults); on(_onSeeSourceAnswersRequested); + on(_onSeeSourceAnswers); } final QuestionsRepository _questionsRepository; @@ -51,9 +53,23 @@ class HomeBloc extends Bloc { ); } + void _onResults( + Results event, + Emitter emit, + ) { + emit(state.copyWith(status: Status.results)); + } + void _onSeeSourceAnswersRequested( SeeSourceAnswersRequested event, Emitter emit, + ) { + emit(state.copyWith(status: Status.resultsToSourceAnswers)); + } + + void _onSeeSourceAnswers( + SeeResultsSourceAnswers event, + Emitter emit, ) { emit(state.copyWith(status: Status.seeSourceAnswers)); } diff --git a/lib/home/bloc/home_event.dart b/lib/home/bloc/home_event.dart index 85a27f8..ed579d2 100644 --- a/lib/home/bloc/home_event.dart +++ b/lib/home/bloc/home_event.dart @@ -27,6 +27,14 @@ class QuestionAsked extends HomeEvent { const QuestionAsked(); } +class Results extends HomeEvent { + const Results(); +} + class SeeSourceAnswersRequested extends HomeEvent { const SeeSourceAnswersRequested(); } + +class SeeResultsSourceAnswers extends HomeEvent { + const SeeResultsSourceAnswers(); +} diff --git a/lib/home/bloc/home_state.dart b/lib/home/bloc/home_state.dart index ae92fa8..7e287c7 100644 --- a/lib/home/bloc/home_state.dart +++ b/lib/home/bloc/home_state.dart @@ -8,7 +8,9 @@ enum Status { thinking, thinkingToResults, results, + resultsToSourceAnswers, seeSourceAnswers, + sourceAnswersBackToResults, } class HomeState extends Equatable { @@ -31,7 +33,10 @@ class HomeState extends Equatable { status == Status.thinking || status == Status.thinkingToResults; bool get isResultsVisible => - status == Status.thinkingToResults || status == Status.results; + status == Status.thinkingToResults || + status == Status.results || + status == Status.resultsToSourceAnswers || + status == Status.seeSourceAnswers; bool get isSeeSourceAnswersVisible => status == Status.seeSourceAnswers; bool get isDashVisible => [ Status.welcome, @@ -40,6 +45,7 @@ class HomeState extends Equatable { Status.askQuestionToThinking, Status.thinkingToResults, Status.results, + Status.resultsToSourceAnswers, ].contains(status); HomeState copyWith({ diff --git a/lib/home/view/home_page.dart b/lib/home/view/home_page.dart index d4851ad..86264a0 100644 --- a/lib/home/view/home_page.dart +++ b/lib/home/view/home_page.dart @@ -40,7 +40,6 @@ class HomeView extends StatelessWidget { if (state.isQuestionVisible) const QuestionView(), if (state.isThinkingVisible) const ThinkingView(), if (state.isResultsVisible) const ResultsView(), - if (state.isSeeSourceAnswersVisible) const SeeSourceAnswers(), if (state.isDashVisible) const Positioned( bottom: 50, diff --git a/lib/home/widgets/dash_animation_container.dart b/lib/home/widgets/dash_animation_container.dart index 8f63eed..5294a70 100644 --- a/lib/home/widgets/dash_animation_container.dart +++ b/lib/home/widgets/dash_animation_container.dart @@ -27,7 +27,8 @@ class _DashAnimationContainerState extends State { Widget build(BuildContext context) { return BlocListener( listener: (context, state) { - if (state.status == Status.askQuestionToThinking) { + if (state.status == Status.askQuestionToThinking || + state.status == Status.resultsToSourceAnswers) { _state.value = DashAnimationPhase.dashOut; } else if (state.status == Status.thinkingToResults) { _state.value = DashAnimationPhase.dashIn; diff --git a/lib/home/widgets/results_view.dart b/lib/home/widgets/results_view.dart index 5a79fa9..408cb7a 100644 --- a/lib/home/widgets/results_view.dart +++ b/lib/home/widgets/results_view.dart @@ -1,9 +1,82 @@ +import 'package:api_client/api_client.dart'; import 'package:app_ui/app_ui.dart'; import 'package:dash_ai_search/home/home.dart'; import 'package:dash_ai_search/l10n/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +/* +class ResultsView extends StatelessWidget { + const ResultsView({super.key}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final l10n = context.l10n; + + final state = context.watch().state; + + final response = + context.select((HomeBloc bloc) => bloc.state.vertexResponse); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 50, vertical: 90), + child: Column( + children: [ + const SearchBox(), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(48, 64, 48, 64), + child: Row( + children: [ + Expanded( + child: Column( + children: [ + Flexible( + child: Text( + response.summary, + style: textTheme.headlineLarge?.copyWith( + color: VertexColors.flutterNavy, + fontSize: 32, + ), + ), + ), + Row( + children: [ + const Expanded(child: FeedbackButtons()), + Expanded( + child: Align( + alignment: Alignment.bottomRight, + child: SizedBox( + height: 64, + child: TertiaryCTA( + label: l10n.seeSourceAnswers, + icon: vertexIcons.arrowForward.image(), + onPressed: () => context + .read() + .add(const SeeSourceAnswersRequested()), + ), + ), + ), + ), + ], + ), + ], + ), + ), + if (state.isSeeSourceAnswersVisible) ...[ + CarouselView(documents: response.documents), + ], + ], + ), + ), + ), + ], + ), + ); + } +} +*/ class ResultsView extends StatefulWidget { const ResultsView({super.key}); @@ -56,79 +129,542 @@ class _ResultsView extends StatelessWidget { @override Widget build(BuildContext context) { - final response = - context.select((HomeBloc bloc) => bloc.state.vertexResponse); - return SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 64), - const SearchBox(), - const SizedBox(height: 32), - Stack( - children: [ - Align(child: BlueContainer(summary: response.summary)), - ], + return const Stack( + children: [ + BlueContainer(), + Positioned( + top: 90, + left: 0, + right: 0, + child: Align( + child: SearchBoxView(), ), - const SizedBox(height: 32), - ], - ), + ), + ], ); } } -class BlueContainer extends StatelessWidget { +class SearchBoxView extends StatefulWidget { @visibleForTesting - const BlueContainer({required this.summary, super.key}); + const SearchBoxView({super.key}); + + @override + State createState() => SearchBoxViewState(); +} + +class SearchBoxViewState extends State + with TickerProviderStateMixin, TransitionScreenMixin { + late Animation _offset; + late Animation _opacity; + + @override + List get forwardEnterStatuses => [Status.thinkingToResults]; - final String summary; + @override + void initializeTransitionController() { + super.initializeTransitionController(); + + enterTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + exitTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + } + + @override + void initState() { + super.initState(); + + _offset = Tween(begin: const Offset(0, 1), end: Offset.zero) + .animate(enterTransitionController); + _opacity = + Tween(begin: 0, end: 1).animate(enterTransitionController); + } @override Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; return Container( - decoration: const BoxDecoration( - color: VertexColors.googleBlue, - borderRadius: BorderRadius.all(Radius.circular(24)), + constraints: const BoxConstraints( + maxWidth: 595, ), - constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700), - padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 64), - child: Stack( - children: [ - Align( - alignment: Alignment.topCenter, - child: Text( - summary, - style: textTheme.headlineLarge - ?.copyWith(color: VertexColors.white, fontSize: 32), + child: SlideTransition( + position: _offset, + child: FadeTransition( + opacity: _opacity, + child: const SearchBox(), + ), + ), + ); + } +} + +class BlueContainer extends StatefulWidget { + @visibleForTesting + const BlueContainer({super.key}); + + @override + State createState() => BlueContainerState(); +} + +class BlueContainerState extends State + with TickerProviderStateMixin, TransitionScreenMixin { + late Animation _offsetEnterIn; + late Animation _rotationEnterIn; + late Animation _positionExitOut; + late Animation _borderRadiusExitOut; + late Animation _sizeIn; + + @override + List get forwardEnterStatuses => [Status.thinkingToResults]; + + @override + List get forwardExitStatuses => [Status.resultsToSourceAnswers]; + + @override + void initializeTransitionController() { + super.initializeTransitionController(); + + enterTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..addStatusListener((status) { + if (status == AnimationStatus.completed) { + context.read().add(const Results()); + } + }); + + exitTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + )..addStatusListener((status) { + final state = context.read().state; + + if (status == AnimationStatus.completed && + state.status == Status.resultsToSourceAnswers) { + context.read().add(const SeeResultsSourceAnswers()); + } + }); + } + + @override + void initState() { + super.initState(); + + _offsetEnterIn = + Tween(begin: const Offset(1, 0), end: Offset.zero).animate( + CurvedAnimation( + parent: enterTransitionController, + curve: Curves.decelerate, + ), + ); + _rotationEnterIn = Tween(begin: 0.2, end: 0).animate( + CurvedAnimation( + parent: enterTransitionController, + curve: Curves.decelerate, + ), + ); + + _positionExitOut = RelativeRectTween( + begin: const RelativeRect.fromLTRB(0, 230, 0, 0), + end: RelativeRect.fill, + ).animate( + CurvedAnimation( + parent: exitTransitionController, + curve: Curves.decelerate, + ), + ); + + _borderRadiusExitOut = Tween(begin: 24, end: 0).animate( + CurvedAnimation( + parent: enterTransitionController, + curve: Curves.decelerate, + ), + ); + + _sizeIn = Tween( + begin: const Size(600, 700), + end: Size.infinite, + ).animate( + CurvedAnimation( + parent: exitTransitionController, + curve: Curves.decelerate, + ), + ); + } + + @override + Widget build(BuildContext context) { + return PositionedTransition( + rect: _positionExitOut, + child: Align( + child: SlideTransition( + position: _offsetEnterIn, + child: RotationTransition( + turns: _rotationEnterIn, + child: AnimatedBuilder( + animation: _sizeIn, + builder: (context, child) { + return Center( + child: Container( + width: _sizeIn.value.width, + height: _sizeIn.value.height, + decoration: BoxDecoration( + color: VertexColors.googleBlue, + borderRadius: BorderRadius.all( + Radius.circular(_borderRadiusExitOut.value), + ), + ), + child: const _AiResponse(), + ), + ); + }, ), ), - const Align( - alignment: Alignment.bottomLeft, - child: FeedbackButtons(), - ), - const Align( - alignment: Alignment.bottomRight, - child: SeeSourceAnswersButton(), + ), + ), + ); + } +} + +class _AiResponse extends StatefulWidget { + const _AiResponse(); + + @override + State<_AiResponse> createState() => _AiResponseState(); +} + +class _AiResponseState extends State<_AiResponse> + with TickerProviderStateMixin, TransitionScreenMixin { + late Animation _leftPaddingExitOut; + late Animation _rightPaddingExitOut; + late Animation _topPaddingExitOut; + late Animation _bottomPaddingExitOut; + + @override + List get forwardEnterStatuses => [Status.thinkingToResults]; + + @override + List get forwardExitStatuses => [Status.resultsToSourceAnswers]; + + @override + List get backEnterStatuses => [Status.sourceAnswersBackToResults]; + + @override + void initializeTransitionController() { + super.initializeTransitionController(); + + enterTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + + exitTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + } + + @override + void initState() { + super.initState(); + + _leftPaddingExitOut = Tween(begin: 48, end: 165).animate( + CurvedAnimation( + parent: exitTransitionController, + curve: Curves.decelerate, + ), + ); + + _rightPaddingExitOut = Tween(begin: 0, end: 150).animate( + CurvedAnimation( + parent: exitTransitionController, + curve: Curves.decelerate, + ), + ); + + _topPaddingExitOut = Tween(begin: 0, end: 155).animate( + CurvedAnimation( + parent: exitTransitionController, + curve: Curves.decelerate, + ), + ); + + _bottomPaddingExitOut = Tween(begin: 172, end: 40).animate( + CurvedAnimation( + parent: exitTransitionController, + curve: Curves.decelerate, + ), + ); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + final state = context.watch().state; + + final response = + context.select((HomeBloc bloc) => bloc.state.vertexResponse); + + return AnimatedBuilder( + animation: _leftPaddingExitOut, + builder: (context, child) => Padding( + padding: EdgeInsets.fromLTRB(_leftPaddingExitOut.value, 64, 48, 64), + child: Row( + children: [ + Expanded( + child: Column( + children: [ + AnimatedBuilder( + animation: _topPaddingExitOut, + builder: (context, child) => + SizedBox(height: _topPaddingExitOut.value), + ), + SizeTransition( + sizeFactor: CurvedAnimation( + parent: exitTransitionController, + curve: Curves.decelerate, + ), + child: const BackToAnswerButton(), + ), + Flexible( + child: Text( + response.summary, + style: textTheme.headlineLarge?.copyWith( + color: VertexColors.white, + fontSize: 32, + ), + ), + ), + AnimatedBuilder( + animation: _bottomPaddingExitOut, + builder: (context, child) => + SizedBox(height: _bottomPaddingExitOut.value), + ), + const Row( + children: [ + Expanded(child: FeedbackButtons()), + Expanded(child: SeeSourceAnswersButton()), + ], + ), + ], + ), + ), + if (state.isSeeSourceAnswersVisible) ...[ + AnimatedBuilder( + animation: _bottomPaddingExitOut, + builder: (context, child) => + SizedBox(width: _rightPaddingExitOut.value), + ), + CarouselView(documents: response.documents), + ], + ], + ), + ), + ); + } +} + +class CarouselView extends StatefulWidget { + @visibleForTesting + const CarouselView({ + required this.documents, + super.key, + }); + + final List documents; + + @override + State createState() => CarouselViewState(); +} + +class CarouselViewState extends State + with TickerProviderStateMixin, TransitionScreenMixin { + late Animation _offsetEnterIn; + late Animation _rotationEnterIn; + + @override + List get forwardEnterStatuses => [Status.resultsToSourceAnswers]; + + @override + void initializeTransitionController() { + super.initializeTransitionController(); + + enterTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + + exitTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + } + + @override + void initState() { + super.initState(); + + _offsetEnterIn = + Tween(begin: const Offset(1, 0), end: Offset.zero).animate( + CurvedAnimation( + parent: enterTransitionController, + curve: Curves.decelerate, + ), + ); + _rotationEnterIn = Tween(begin: 0.2, end: 0).animate( + CurvedAnimation( + parent: enterTransitionController, + curve: Curves.decelerate, + ), + ); + } + + @override + Widget build(BuildContext context) { + return SlideTransition( + position: _offsetEnterIn, + child: RotationTransition( + turns: _rotationEnterIn, + child: SourcesCarouselView( + documents: widget.documents, + ), + ), + ); + } +} + +class BackToAnswerButton extends StatefulWidget { + @visibleForTesting + const BackToAnswerButton({super.key}); + + @override + State createState() => _BackToAnswerButtonState(); +} + +class _BackToAnswerButtonState extends State + with TickerProviderStateMixin, TransitionScreenMixin { + late Animation _sizeExitIn; + + @override + List get forwardEnterStatuses => [Status.thinkingToResults]; + + @override + List get forwardExitStatuses => [Status.resultsToSourceAnswers]; + + @override + List get backEnterStatuses => [Status.sourceAnswersBackToResults]; + + @override + void initializeTransitionController() { + super.initializeTransitionController(); + + enterTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + + exitTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + } + + @override + void initState() { + super.initState(); + + _sizeExitIn = CurvedAnimation( + parent: exitTransitionController, + curve: Curves.decelerate, + ); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return SizeTransition( + sizeFactor: _sizeExitIn, + axis: Axis.horizontal, + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: 64, + child: TertiaryCTA( + label: l10n.backToAIAnswer, + icon: vertexIcons.arrowBack.image(color: VertexColors.white), ), - ], + ), ), ); } } -class SeeSourceAnswersButton extends StatelessWidget { +class SeeSourceAnswersButton extends StatefulWidget { + @visibleForTesting const SeeSourceAnswersButton({super.key}); + @override + State createState() => _SeeSourceAnswersButtonState(); +} + +class _SeeSourceAnswersButtonState extends State + with TickerProviderStateMixin, TransitionScreenMixin { + late Animation _opacityExitOut; + + @override + List get forwardEnterStatuses => [Status.thinkingToResults]; + + @override + List get forwardExitStatuses => [Status.resultsToSourceAnswers]; + + @override + List get backEnterStatuses => [Status.sourceAnswersBackToResults]; + + @override + void initializeTransitionController() { + super.initializeTransitionController(); + + enterTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + + exitTransitionController = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + } + + @override + void initState() { + super.initState(); + _opacityExitOut = + Tween(begin: 1, end: 0).animate(exitTransitionController); + } + @override Widget build(BuildContext context) { final l10n = context.l10n; - return SizedBox( - height: 64, - child: TertiaryCTA( - label: l10n.seeSourceAnswers, - icon: vertexIcons.arrowForward.image(), - onPressed: () => - context.read().add(const SeeSourceAnswersRequested()), + + return FadeTransition( + opacity: _opacityExitOut, + child: Align( + alignment: Alignment.bottomRight, + child: SizedBox( + height: 64, + child: TertiaryCTA( + label: l10n.seeSourceAnswers, + icon: vertexIcons.arrowForward.image(), + onPressed: () => + context.read().add(const SeeSourceAnswersRequested()), + ), + ), ), ); } diff --git a/lib/home/widgets/see_source_answers.dart b/lib/home/widgets/see_source_answers.dart deleted file mode 100644 index f51fccd..0000000 --- a/lib/home/widgets/see_source_answers.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:app_ui/app_ui.dart'; -import 'package:dash_ai_search/home/home.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class SeeSourceAnswers extends StatelessWidget { - const SeeSourceAnswers({super.key}); - - @override - Widget build(BuildContext context) { - final response = - context.select((HomeBloc bloc) => bloc.state.vertexResponse); - return SizedBox.expand( - child: ColoredBox( - color: VertexColors.googleBlue, - child: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 64), - const SearchBox(), - const SizedBox(height: 32), - Row( - children: [ - Expanded(child: _AiResponse(response.summary)), - const SizedBox(width: 150), - Expanded( - child: SourcesCarouselView(documents: response.documents), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -class _AiResponse extends StatelessWidget { - const _AiResponse(this.text); - - final String text; - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 50), - child: Column( - children: [ - Text( - text, - style: textTheme.headlineLarge?.copyWith( - color: VertexColors.white, - ), - ), - const SizedBox(height: 20), - const FeedbackButtons(), - ], - ), - ); - } -} diff --git a/lib/home/widgets/widgets.dart b/lib/home/widgets/widgets.dart index b3cfd0a..f574598 100644 --- a/lib/home/widgets/widgets.dart +++ b/lib/home/widgets/widgets.dart @@ -5,7 +5,6 @@ export 'logo.dart'; export 'question_view.dart'; export 'results_view.dart'; export 'search_box.dart'; -export 'see_source_answers.dart'; export 'sources_carousel_view.dart'; export 'thinking_view.dart'; export 'transition_screen_mixin.dart'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 4786d01..3bebbc5 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -35,5 +35,9 @@ "seeSourceAnswers": "See source answers", "@seeSourceAnswers": { "description": "Text in see source answers button." + }, + "backToAIAnswer": "Back to AI answer", + "@backToAIAnswer": { + "description": "Text in back to AI answer button." } } \ No newline at end of file diff --git a/packages/app_ui/assets/icons/arrow_back.png b/packages/app_ui/assets/icons/arrow_back.png new file mode 100644 index 0000000..9c675b8 Binary files /dev/null and b/packages/app_ui/assets/icons/arrow_back.png differ diff --git a/packages/app_ui/lib/src/generated/assets.gen.dart b/packages/app_ui/lib/src/generated/assets.gen.dart index 98cc8ae..662d48c 100644 --- a/packages/app_ui/lib/src/generated/assets.gen.dart +++ b/packages/app_ui/lib/src/generated/assets.gen.dart @@ -12,6 +12,10 @@ import 'package:flutter/widgets.dart'; class $AssetsIconsGen { const $AssetsIconsGen(); + /// File path: assets/icons/arrow_back.png + AssetGenImage get arrowBack => + const AssetGenImage('assets/icons/arrow_back.png'); + /// File path: assets/icons/arrow_forward.png AssetGenImage get arrowForward => const AssetGenImage('assets/icons/arrow_forward.png'); @@ -24,7 +28,7 @@ class $AssetsIconsGen { AssetGenImage get stars => const AssetGenImage('assets/icons/stars.png'); /// List of all assets - List get values => [arrowForward, asterisk, stars]; + List get values => [arrowBack, arrowForward, asterisk, stars]; } class $AssetsImagesGen { diff --git a/packages/app_ui/lib/src/widgets/tertiary_cta.dart b/packages/app_ui/lib/src/widgets/tertiary_cta.dart index 66f2d02..8196d3d 100644 --- a/packages/app_ui/lib/src/widgets/tertiary_cta.dart +++ b/packages/app_ui/lib/src/widgets/tertiary_cta.dart @@ -31,13 +31,15 @@ class TertiaryCTA extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (icon != null) icon!, - Text( - label, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: VertexColors.white, + Expanded( + child: Text( + label, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: VertexColors.white, + ), ), ), ], diff --git a/test/home/bloc/home_bloc_test.dart b/test/home/bloc/home_bloc_test.dart index 3f03002..076938c 100644 --- a/test/home/bloc/home_bloc_test.dart +++ b/test/home/bloc/home_bloc_test.dart @@ -80,11 +80,33 @@ void main() { ); }); + group('Results', () { + blocTest( + 'emits Status.results', + build: buildBloc, + act: (bloc) => bloc.add(Results()), + expect: () => [ + HomeState(status: Status.results), + ], + ); + }); + group('SeeSourceAnswersRequested', () { blocTest( - 'emits Status.seeSourceAnswers', + 'emits Status.resultsToSourceAnswers', build: buildBloc, act: (bloc) => bloc.add(SeeSourceAnswersRequested()), + expect: () => [ + HomeState(status: Status.resultsToSourceAnswers), + ], + ); + }); + + group('SeeResultsSourceAnswers', () { + blocTest( + 'emits Status.seeSourceAnswers', + build: buildBloc, + act: (bloc) => bloc.add(SeeResultsSourceAnswers()), expect: () => [ HomeState(status: Status.seeSourceAnswers), ], diff --git a/test/home/view/home_page_test.dart b/test/home/view/home_page_test.dart index 371cbcd..7840954 100644 --- a/test/home/view/home_page_test.dart +++ b/test/home/view/home_page_test.dart @@ -1,7 +1,5 @@ -import 'package:api_client/api_client.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:dash_ai_search/home/home.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -86,93 +84,10 @@ void main() { expect(find.byType(ThinkingView), findsOneWidget); }); - group('ResultsView', () { - testWidgets('is rendered if isResultsVisible', (tester) async { - when(() => homeBloc.state).thenReturn( - HomeState( - status: Status.results, - ), - ); - await tester.pumpApp( - BlocProvider.value( - value: homeBloc, - child: HomeView(), - ), - ); - - expect(find.byType(ResultsView), findsOneWidget); - }); - - testWidgets('adds SeeSourceAnswersRequested to bloc', (tester) async { - when(() => homeBloc.state).thenReturn( - HomeState(status: Status.results), - ); - await tester.pumpApp( - BlocProvider.value( - value: homeBloc, - child: HomeView(), - ), - ); - final answersFinder = find.byType(SeeSourceAnswersButton); - await tester.dragUntilVisible( - answersFinder, - find.byType(SingleChildScrollView), - const Offset(0, 10), - ); - await tester.tap(find.byType(SeeSourceAnswersButton)); - verify(() => homeBloc.add(const SeeSourceAnswersRequested())).called(1); - }); - }); - - testWidgets('renders SeeSourceAnswers if isSeeSourceAnswersVisible', - (tester) async { + testWidgets('renders ResultsView if isResultsVisible', (tester) async { when(() => homeBloc.state).thenReturn( HomeState( - status: Status.seeSourceAnswers, - vertexResponse: VertexResponse( - summary: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, ' - 'sed do eiusmod tempor incididunt ut labore et dolore magna ' - 'aliqua. Ut enim ad minim veniam, quis nostrud exercitation ' - 'ullamco laboris nisi ut aliquip ex ea commodo consequat. ' - 'Duis aute irure dolor in reprehenderit in voluptate velit ' - 'esse cillum dolore eu fugiat nulla pariatur. Excepteur sint ' - 'occaecat cupidatat non proident, sunt in culpa qui officia' - ' deserunt mollit anim id est laborum.', - documents: const [ - VertexDocument( - id: '1', - metadata: VertexMetadata( - url: 'url', - title: 'title', - description: 'description', - ), - ), - VertexDocument( - id: '2', - metadata: VertexMetadata( - url: 'url', - title: 'title', - description: 'description', - ), - ), - VertexDocument( - id: '3', - metadata: VertexMetadata( - url: 'url', - title: 'title', - description: 'description', - ), - ), - VertexDocument( - id: '4', - metadata: VertexMetadata( - url: 'url', - title: 'title', - description: 'description', - ), - ), - ], - ), + status: Status.results, ), ); await tester.pumpApp( @@ -182,7 +97,7 @@ void main() { ), ); - expect(find.byType(SeeSourceAnswers), findsOneWidget); + expect(find.byType(ResultsView), findsOneWidget); }); }); } diff --git a/test/home/widgets/dash_animation_container_test.dart b/test/home/widgets/dash_animation_container_test.dart index 6806149..68b73e6 100644 --- a/test/home/widgets/dash_animation_container_test.dart +++ b/test/home/widgets/dash_animation_container_test.dart @@ -71,6 +71,13 @@ void main() { await tester.pump(); expect(state.value, equals(DashAnimationPhase.dashIn)); + + animationController.add( + const HomeState(status: Status.resultsToSourceAnswers), + ); + await tester.pump(); + + expect(state.value, equals(DashAnimationPhase.dashOut)); }); }); } diff --git a/test/home/widgets/results_view_test.dart b/test/home/widgets/results_view_test.dart index 2d0c039..4fcfd85 100644 --- a/test/home/widgets/results_view_test.dart +++ b/test/home/widgets/results_view_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:api_client/api_client.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:dash_ai_search/home/home.dart'; import 'package:flutter/material.dart'; @@ -13,10 +16,56 @@ class _MockHomeBloc extends MockBloc void main() { group('ResultsView', () { late HomeBloc homeBloc; + const response = VertexResponse( + summary: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, ' + 'sed do eiusmod tempor incididunt ut labore et dolore magna ' + 'aliqua. Ut enim ad minim veniam, quis nostrud exercitation ' + 'ullamco laboris nisi ut aliquip ex ea commodo consequat. ' + 'Duis aute irure dolor in reprehenderit in voluptate velit ' + 'esse cillum dolore eu fugiat nulla pariatur. Excepteur sint ' + 'occaecat cupidatat non proident, sunt in culpa qui officia' + ' deserunt mollit anim id est laborum.', + documents: [ + VertexDocument( + id: '1', + metadata: VertexMetadata( + url: 'url', + title: 'title', + description: 'description', + ), + ), + VertexDocument( + id: '2', + metadata: VertexMetadata( + url: 'url', + title: 'title', + description: 'description', + ), + ), + VertexDocument( + id: '3', + metadata: VertexMetadata( + url: 'url', + title: 'title', + description: 'description', + ), + ), + VertexDocument( + id: '4', + metadata: VertexMetadata( + url: 'url', + title: 'title', + description: 'description', + ), + ), + ], + ); setUp(() { homeBloc = _MockHomeBloc(); - when(() => homeBloc.state).thenReturn(HomeState()); + when(() => homeBloc.state).thenReturn( + HomeState(vertexResponse: response), + ); }); Widget bootstrap() => BlocProvider.value( @@ -24,30 +73,110 @@ void main() { child: Material(child: ResultsView()), ); - testWidgets('renders correctly', (tester) async { + testWidgets('renders the response summary', (tester) async { await tester.pumpApp(bootstrap()); + expect(find.text(response.summary), findsOneWidget); + }); - expect(find.byType(BlueContainer), findsOneWidget); + testWidgets('renders SearchBox', (tester) async { + await tester.pumpApp(bootstrap()); + expect(find.byType(SearchBox), findsOneWidget); }); - testWidgets('animates in when enter', (tester) async { + testWidgets('renders CarouselView', (tester) async { + when(() => homeBloc.state).thenReturn( + HomeState(vertexResponse: response, status: Status.seeSourceAnswers), + ); + await tester.pumpApp(bootstrap()); + expect(find.byType(CarouselView), findsOneWidget); + }); + + testWidgets('animates in search box when enter', (tester) async { await tester.pumpApp(bootstrap()); final forwardEnterStatuses = tester - .state(find.byType(ResultsView)) + .state(find.byType(SearchBoxView)) .forwardEnterStatuses; expect(forwardEnterStatuses, equals([Status.thinkingToResults])); }); - testWidgets('animates out when exits forward', (tester) async { + testWidgets('animates in BlueContainer when enter', (tester) async { + await tester.pumpApp(bootstrap()); + + final forwardEnterStatuses = tester + .state(find.byType(BlueContainer)) + .forwardEnterStatuses; + + expect(forwardEnterStatuses, equals([Status.thinkingToResults])); + }); + + testWidgets('animates out BlueContainer when exits forward', + (tester) async { await tester.pumpApp(bootstrap()); final forwardExitStatuses = tester - .state(find.byType(ResultsView)) + .state(find.byType(BlueContainer)) .forwardExitStatuses; - expect(forwardExitStatuses, equals([Status.results])); + expect(forwardExitStatuses, equals([Status.resultsToSourceAnswers])); + }); + + testWidgets( + 'calls Results on enter', + (WidgetTester tester) async { + await tester.pumpApp(bootstrap()); + await tester.pumpAndSettle(); + verify(() => homeBloc.add(Results())).called(1); + }, + ); + + testWidgets( + 'calls SeeResultsSourceAnswers on exit', + (WidgetTester tester) async { + final controller = StreamController(); + whenListen( + homeBloc, + controller.stream, + initialState: const HomeState(), + ); + + await tester.pumpApp(bootstrap()); + await tester.pumpAndSettle(); + + controller.add(const HomeState(status: Status.resultsToSourceAnswers)); + await tester.pumpAndSettle(); + + verify(() => homeBloc.add(SeeResultsSourceAnswers())).called(1); + }, + ); + + testWidgets('animates in CarouselView when enter', (tester) async { + when(() => homeBloc.state).thenReturn( + HomeState( + vertexResponse: response, + status: Status.seeSourceAnswers, + ), + ); + + await tester.pumpApp(bootstrap()); + + final forwardEnterStatuses = tester + .state(find.byType(CarouselView)) + .forwardEnterStatuses; + + expect(forwardEnterStatuses, equals([Status.resultsToSourceAnswers])); + }); + + testWidgets( + 'calls SeeSourceAnswersRequested on SeeSourceAnswersButton tapped', + (tester) async { + await tester.pumpApp(bootstrap()); + + await tester.pumpAndSettle(); + await tester.tap(find.byType(SeeSourceAnswersButton)); + + verify(() => homeBloc.add(const SeeSourceAnswersRequested())).called(1); }); }); } diff --git a/test/home/widgets/see_source_answers_test.dart b/test/home/widgets/see_source_answers_test.dart deleted file mode 100644 index e2e11df..0000000 --- a/test/home/widgets/see_source_answers_test.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:api_client/api_client.dart'; -import 'package:bloc_test/bloc_test.dart'; -import 'package:dash_ai_search/home/home.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import '../../helpers/helpers.dart'; - -class _MockHomeBloc extends MockBloc - implements HomeBloc {} - -void main() { - group('SeeSourceAnswers', () { - late HomeBloc homeBloc; - const response = VertexResponse( - summary: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, ' - 'sed do eiusmod tempor incididunt ut labore et dolore magna ' - 'aliqua. Ut enim ad minim veniam, quis nostrud exercitation ' - 'ullamco laboris nisi ut aliquip ex ea commodo consequat. ' - 'Duis aute irure dolor in reprehenderit in voluptate velit ' - 'esse cillum dolore eu fugiat nulla pariatur. Excepteur sint ' - 'occaecat cupidatat non proident, sunt in culpa qui officia' - ' deserunt mollit anim id est laborum.', - documents: [ - VertexDocument( - id: '1', - metadata: VertexMetadata( - url: 'url', - title: 'title', - description: 'description', - ), - ), - VertexDocument( - id: '2', - metadata: VertexMetadata( - url: 'url', - title: 'title', - description: 'description', - ), - ), - VertexDocument( - id: '3', - metadata: VertexMetadata( - url: 'url', - title: 'title', - description: 'description', - ), - ), - VertexDocument( - id: '4', - metadata: VertexMetadata( - url: 'url', - title: 'title', - description: 'description', - ), - ), - ], - ); - - setUp(() { - homeBloc = _MockHomeBloc(); - when(() => homeBloc.state).thenReturn( - HomeState(vertexResponse: response), - ); - }); - - testWidgets('renders the response summary', (tester) async { - await tester.pumpApp( - BlocProvider.value( - value: homeBloc, - child: Material(child: SeeSourceAnswers()), - ), - ); - expect(find.text(response.summary), findsOneWidget); - }); - - testWidgets('renders SearchBox', (tester) async { - await tester.pumpApp( - BlocProvider.value( - value: homeBloc, - child: Material(child: SeeSourceAnswers()), - ), - ); - expect(find.byType(SearchBox), findsOneWidget); - }); - - testWidgets('renders SourcesCarouselView', (tester) async { - await tester.pumpApp( - BlocProvider.value( - value: homeBloc, - child: Material(child: SeeSourceAnswers()), - ), - ); - expect(find.byType(SourcesCarouselView), findsOneWidget); - }); - }); -}