diff --git a/lib/home/view/home_page.dart b/lib/home/view/home_page.dart index 60cd8d0..0608bd4 100644 --- a/lib/home/view/home_page.dart +++ b/lib/home/view/home_page.dart @@ -28,7 +28,8 @@ class HomeView extends StatelessWidget { final status = context.select((HomeBloc bloc) => bloc.state.status); return Scaffold( - backgroundColor: VertexColors.arctic, + backgroundColor: + status.isWelcomeVisible ? VertexColors.arctic : VertexColors.white, body: Stack( children: [ if (status.isWelcomeVisible) diff --git a/lib/home/widgets/question_view.dart b/lib/home/widgets/question_view.dart index 299ccb5..5bce510 100644 --- a/lib/home/widgets/question_view.dart +++ b/lib/home/widgets/question_view.dart @@ -90,7 +90,9 @@ class QuestionViewState extends State ClipRRect( child: SlideTransition( position: _offsetVerticalOut, - child: const SearchBox(), + child: const SearchBox( + shouldAnimate: true, + ), ), ), ], diff --git a/lib/home/widgets/search_box.dart b/lib/home/widgets/search_box.dart index 3678d06..a7136fc 100644 --- a/lib/home/widgets/search_box.dart +++ b/lib/home/widgets/search_box.dart @@ -5,9 +5,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SearchBox extends StatelessWidget { - const SearchBox({this.askAgain = false, super.key}); + const SearchBox({ + this.shouldAnimate = false, + this.askAgain = false, + super.key, + }); final bool askAgain; + final bool shouldAnimate; @override Widget build(BuildContext context) { @@ -17,6 +22,7 @@ class SearchBox extends StatelessWidget { final searchQuery = state.query; final submittedQuery = state.submittedQuery; return QuestionInputTextField( + shouldAnimate: shouldAnimate, shouldDisplayClearTextButton: searchQuery == submittedQuery, icon: vertexIcons.stars.image(), hint: l10n.questionHint, diff --git a/packages/app_ui/lib/src/theme/vertex_theme.dart b/packages/app_ui/lib/src/theme/vertex_theme.dart index 4a83be5..35acc77 100644 --- a/packages/app_ui/lib/src/theme/vertex_theme.dart +++ b/packages/app_ui/lib/src/theme/vertex_theme.dart @@ -36,25 +36,22 @@ class VertexTheme { hintStyle: VertexTextStyles.bodyLargeRegular .copyWith(color: VertexColors.mediumGrey), isDense: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), - borderSide: const BorderSide( - color: VertexColors.googleBlue, - ), - ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(100), borderSide: const BorderSide( color: VertexColors.googleBlue, + width: 2, ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(100), borderSide: const BorderSide( color: VertexColors.googleBlue, + width: 2, ), ), contentPadding: const EdgeInsets.symmetric(vertical: 32), + hoverColor: Colors.transparent, ); } diff --git a/packages/app_ui/lib/src/widgets/question_input_text_field.dart b/packages/app_ui/lib/src/widgets/question_input_text_field.dart index 72e31a0..ef64bb1 100644 --- a/packages/app_ui/lib/src/widgets/question_input_text_field.dart +++ b/packages/app_ui/lib/src/widgets/question_input_text_field.dart @@ -14,6 +14,7 @@ class QuestionInputTextField extends StatefulWidget { required this.onTextUpdated, required this.onActionPressed, this.shouldDisplayClearTextButton = false, + this.shouldAnimate = false, this.text, super.key, }); @@ -40,12 +41,32 @@ class QuestionInputTextField extends StatefulWidget { /// that clears the text field final bool shouldDisplayClearTextButton; + /// It indicates if the text field will have animation or not. + /// Defaults to `false` + final bool shouldAnimate; + + /// Key to find the animated builder for the text field. Used for testing. + @visibleForTesting + static const Key textFieldAnimatedBuilderKey = + Key('text_field_animated_builder'); + + /// Key to find the animated builder for the hint. Used for testing. + @visibleForTesting + static const Key hintAnimatedBuilderKey = Key('hint_animated_builder'); + @override State createState() => _QuestionTextFieldState(); } -class _QuestionTextFieldState extends State { +class _QuestionTextFieldState extends State + with TickerProviderStateMixin { late final TextEditingController _controller; + late final AnimationController _hintAnimationController; + late final AnimationController _textFieldAnimationController; + + late Animation _textFieldAnimationSize; + late Animation _hintAnimationPadding; + static const _width = 659.0; @override void initState() { @@ -54,11 +75,39 @@ class _QuestionTextFieldState extends State { _controller.addListener(() { widget.onTextUpdated(_controller.text); }); + _textFieldAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + _hintAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _hintAnimationPadding = + Tween(begin: 16, end: 600).animate(_hintAnimationController); + _textFieldAnimationSize = Tween(begin: _width, end: 0).animate( + CurvedAnimation( + parent: _textFieldAnimationController, + curve: Curves.decelerate, + ), + ); + + if (widget.shouldAnimate) { + _textFieldAnimationController + ..forward() + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + _hintAnimationController.forward(); + } + }); + } } @override void dispose() { _controller.dispose(); + _textFieldAnimationController.dispose(); + _hintAnimationController.dispose(); super.dispose(); } @@ -66,37 +115,71 @@ class _QuestionTextFieldState extends State { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return Container( - constraints: const BoxConstraints(maxWidth: 659), - child: TextField( - controller: _controller, - style: textTheme.bodyMedium?.copyWith( - color: VertexColors.flutterNavy, - ), - autofillHints: null, - onSubmitted: (_) => widget.onActionPressed(), - decoration: InputDecoration( - filled: true, - fillColor: VertexColors.arctic, - prefixIcon: Padding( - padding: const EdgeInsets.only(left: 12), - child: widget.icon, + constraints: const BoxConstraints(maxWidth: _width, maxHeight: 100), + child: Stack( + fit: StackFit.expand, + children: [ + Align( + child: TextField( + controller: _controller, + style: textTheme.bodyMedium?.copyWith( + color: VertexColors.flutterNavy, + ), + autofillHints: null, + onSubmitted: (_) => widget.onActionPressed(), + decoration: InputDecoration( + filled: true, + prefixIcon: Padding( + padding: const EdgeInsets.only(left: 12), + child: widget.icon, + ), + hintText: widget.hint, + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 12), + child: widget.shouldDisplayClearTextButton + ? IconButton( + onPressed: () { + _controller.clear(); + }, + icon: const Icon(Icons.close), + ) + : PrimaryCTA( + label: widget.actionText, + onPressed: () => widget.onActionPressed(), + ), + ), + ), + ), ), - hintText: widget.hint, - suffixIcon: Padding( - padding: const EdgeInsets.only(right: 12), - child: widget.shouldDisplayClearTextButton - ? IconButton( - onPressed: () { - _controller.clear(); - }, - icon: const Icon(Icons.close), - ) - : PrimaryCTA( - label: widget.actionText, - onPressed: () => widget.onActionPressed(), + if (widget.shouldAnimate) ...[ + Align( + alignment: Alignment.centerRight, + child: AnimatedBuilder( + key: QuestionInputTextField.textFieldAnimatedBuilderKey, + animation: _textFieldAnimationController, + builder: (_, __) => Container( + color: VertexColors.white, + width: _textFieldAnimationSize.value, + ), + ), + ), + Align( + child: AnimatedBuilder( + key: QuestionInputTextField.hintAnimatedBuilderKey, + animation: _hintAnimationController, + builder: (_, __) => Container( + color: VertexColors.white, + margin: EdgeInsets.only( + top: 32, + bottom: 32, + right: 120, + left: _hintAnimationPadding.value, ), - ), - ), + ), + ), + ), + ], + ], ), ); } diff --git a/packages/app_ui/test/src/widgets/question_input_text_field_test.dart b/packages/app_ui/test/src/widgets/question_input_text_field_test.dart index c4819c4..8634234 100644 --- a/packages/app_ui/test/src/widgets/question_input_text_field_test.dart +++ b/packages/app_ui/test/src/widgets/question_input_text_field_test.dart @@ -6,7 +6,7 @@ import '../helpers/helpers.dart'; void main() { group('QuestionInputTextField', () { - testWidgets('renders correctly', (tester) async { + testWidgets('renders correctly without animation', (tester) async { await tester.pumpApp( Material( child: QuestionInputTextField( @@ -23,6 +23,42 @@ void main() { expect(find.byType(PrimaryCTA), findsOneWidget); }); + testWidgets('renders correctly with animation', (tester) async { + await tester.pumpApp( + Material( + child: QuestionInputTextField( + icon: SizedBox.shrink(), + hint: 'hint', + actionText: 'actionText', + onActionPressed: () {}, + onTextUpdated: (_) {}, + shouldAnimate: true, + ), + ), + ); + + expect(find.text('hint'), findsOneWidget); + expect(find.byType(PrimaryCTA), findsOneWidget); + final textFieldAnimationController = tester + .widget( + find.byKey(QuestionInputTextField.textFieldAnimatedBuilderKey), + ) + .animation as AnimationController; + final hintAnimationController = tester + .widget( + find.byKey(QuestionInputTextField.hintAnimatedBuilderKey), + ) + .animation as AnimationController; + expect(textFieldAnimationController.status, AnimationStatus.forward); + expect(hintAnimationController.status, AnimationStatus.dismissed); + await tester.pump(Duration(milliseconds: 1201)); + expect(textFieldAnimationController.status, AnimationStatus.completed); + expect(hintAnimationController.status, AnimationStatus.forward); + await tester.pump(Duration(milliseconds: 601)); + expect(textFieldAnimationController.status, AnimationStatus.completed); + expect(hintAnimationController.status, AnimationStatus.completed); + }); + testWidgets('calls onTextUpdated typing on the text field', (tester) async { var text = ''; await tester.pumpApp(