Skip to content
This repository has been archived by the owner on Jan 9, 2024. It is now read-only.

Commit

Permalink
feat: CTA animation (#61)
Browse files Browse the repository at this point in the history
  • Loading branch information
omartinma authored Dec 1, 2023
1 parent c7bd757 commit be2c7e1
Show file tree
Hide file tree
Showing 7 changed files with 259 additions and 31 deletions.
2 changes: 1 addition & 1 deletion lib/home/widgets/welcome_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class _WelcomeView extends StatelessWidget {
),
),
const SizedBox(height: 40),
PrimaryCTA(
PrimaryIconCTA(
icon: vertexIcons.arrowForward.image(
color: VertexColors.googleBlue,
),
Expand Down
31 changes: 6 additions & 25 deletions packages/app_ui/lib/src/widgets/primary_cta.dart
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
import 'package:app_ui/app_ui.dart';
import 'package:flutter/material.dart';

/// {@template primary_cta}
/// A button that displays an image on the left side and a text on the right
/// side.
/// PrimaryCTA
/// {@endtemplate}
class PrimaryCTA extends StatelessWidget {
/// {@macro primary_cta}
const PrimaryCTA({
required this.label,
this.icon,
this.onPressed,
super.key,
});

/// The widget that will be displayed on the left side of the button.
final Widget? icon;

/// The text that will be displayed on the right side of the button.
final String label;

Expand All @@ -28,29 +22,16 @@ class PrimaryCTA extends StatelessWidget {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
padding: EdgeInsets.only(
left: icon != null ? 8 : 32,
padding: const EdgeInsets.only(
left: 32,
top: 20,
bottom: 20,
right: 32,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
Padding(
padding: const EdgeInsets.only(right: 16),
child: CircleAvatar(
backgroundColor: VertexColors.white,
child: SizedBox.square(dimension: 24, child: icon),
),
),
Text(
label,
textAlign: TextAlign.center,
),
],
child: Text(
label,
textAlign: TextAlign.center,
),
);
}
Expand Down
179 changes: 179 additions & 0 deletions packages/app_ui/lib/src/widgets/primary_icon_cta.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import 'package:app_ui/app_ui.dart';
import 'package:flutter/material.dart';

/// {@template primary_icon_cta}
/// A button that displays an image on the left side and a text on the right
/// side.
/// {@endtemplate}
class PrimaryIconCTA extends StatefulWidget {
/// {@macro primary_icon_cta}
const PrimaryIconCTA({
required this.label,
this.icon,
this.onPressed,
super.key,
});

/// The widget that will be displayed on the left side of the button.
final Widget? icon;

/// The text that will be displayed on the right side of the button.
final String label;

/// The callback that will be called when the button is tapped.
final VoidCallback? onPressed;

/// Key to find the animated builder. Used for testing.
@visibleForTesting
static const Key animatedBuilderKey = Key('animated_builder');

@override
State<PrimaryIconCTA> createState() => _PrimaryCTAIconState();
}

class _PrimaryCTAIconState extends State<PrimaryIconCTA>
with SingleTickerProviderStateMixin {
static const _buttonWidth = 200.0;
static const _buttonHeight = 64.0;
static const _circleWidth = 50.0;
static const _iconSize = 24.0;
static const _iconAndTextSeparation = _circleWidth + 18.0;

@visibleForTesting
late final AnimationController controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
late Animation<double> _width;
late Animation<double> _opacityMainIcon;
late Animation<double> _opacityOffsetIcon;
late Animation<Offset> _offset;
late Animation<double> _iconDimension;
late Animation<double> _padding;

@override
void initState() {
super.initState();
_width = Tween<double>(begin: _circleWidth, end: _buttonWidth)
.animate(controller);
_opacityMainIcon = Tween<double>(begin: 1, end: 0.1).animate(controller);
_opacityOffsetIcon = Tween<double>(begin: 0, end: 1).animate(controller);
_offset = Tween<Offset>(begin: const Offset(-60, 0), end: Offset.zero)
.animate(controller);
_iconDimension =
Tween<double>(begin: _iconSize, end: 0).animate(controller);
_padding = Tween<double>(begin: 8, end: 0).animate(controller);
}

@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: widget.onPressed,
style: ElevatedButton.styleFrom(
padding: EdgeInsets.zero,
),
onHover: (hovered) {
if (hovered) {
controller.forward(from: 0);
} else {
controller.reverse(from: 1);
}
},
child: SizedBox(
height: _buttonHeight,
width: _buttonWidth,
child: Stack(
children: [
if (widget.icon != null)
Align(
alignment: Alignment.centerLeft,
child: AnimatedBuilder(
key: PrimaryIconCTA.animatedBuilderKey,
animation: controller,
builder: (_, __) {
return Padding(
padding: EdgeInsets.all(_padding.value),
child: Opacity(
opacity: _opacityMainIcon.value,
child: _Icon(
icon: widget.icon!,
width: _width.value,
iconDimension: _iconDimension.value,
hideIcon: controller.isAnimating,
),
),
);
},
),
),
if (widget.icon != null)
Align(
alignment: Alignment.centerLeft,
child: AnimatedBuilder(
animation: controller,
builder: (_, __) {
return Transform.translate(
offset: _offset.value,
child: Padding(
padding: const EdgeInsets.all(8),
child: Opacity(
opacity: _opacityOffsetIcon.value,
child: _Icon(
icon: widget.icon!,
width: _circleWidth,
iconDimension: _iconSize,
hideIcon: _offset.value != Offset.zero,
),
),
),
);
},
),
),
Positioned(
left: _iconAndTextSeparation,
child: Container(
height: _buttonHeight,
alignment: Alignment.centerLeft,
child: Text(
widget.label,
textAlign: TextAlign.center,
),
),
),
],
),
),
);
}
}

class _Icon extends StatelessWidget {
const _Icon({
required this.icon,
required this.width,
required this.iconDimension,
required this.hideIcon,
});

final Widget icon;
final double width;
final double iconDimension;
final bool hideIcon;

@override
Widget build(BuildContext context) {
return Container(
width: width,
height: width,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: VertexColors.white,
borderRadius: BorderRadius.all(Radius.circular(100)),
),
child: hideIcon
? const SizedBox()
: SizedBox.square(dimension: iconDimension, child: icon),
);
}
}
1 change: 1 addition & 0 deletions packages/app_ui/lib/src/widgets/widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export 'app_animated_cross_fade.dart';
export 'app_circular_progress_indicator.dart';
export 'feedback_buttons.dart';
export 'primary_cta.dart';
export 'primary_icon_cta.dart';
export 'question_input_text_field.dart';
export 'tertiary_cta.dart';
3 changes: 0 additions & 3 deletions packages/app_ui/test/src/widgets/primary_cta_test.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import 'package:app_ui/app_ui.dart';
import 'package:app_ui/src/generated/assets.gen.dart';
import 'package:flutter_test/flutter_test.dart';

import '../helpers/helpers.dart';
Expand All @@ -9,7 +8,6 @@ void main() {
testWidgets('renders correctly', (tester) async {
await tester.pumpApp(
PrimaryCTA(
icon: Assets.icons.arrowForward.image(),
label: 'label',
),
);
Expand All @@ -22,7 +20,6 @@ void main() {

await tester.pumpApp(
PrimaryCTA(
icon: Assets.icons.arrowForward.image(),
label: 'label',
onPressed: () {
called = true;
Expand Down
70 changes: 70 additions & 0 deletions packages/app_ui/test/src/widgets/primary_icon_cta_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import 'package:app_ui/app_ui.dart';
import 'package:app_ui/src/generated/assets.gen.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('PrimaryIconCTA', () {
testWidgets('renders correctly', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: PrimaryIconCTA(
icon: Assets.icons.arrowForward.image(),
label: 'label',
),
),
);

expect(find.text('label'), findsOneWidget);
});

testWidgets('calls onPressed when tap', (tester) async {
var called = false;

await tester.pumpWidget(
MaterialApp(
home: PrimaryIconCTA(
icon: Assets.icons.arrowForward.image(),
label: 'label',
onPressed: () {
called = true;
},
),
),
);

await tester.tap(find.byType(PrimaryIconCTA));
expect(called, isTrue);
});

testWidgets('has hover animation', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: PrimaryIconCTA(
icon: Assets.icons.arrowForward.image(),
label: 'label',
onPressed: () {},
),
),
);
final animationController = tester
.widget<AnimatedBuilder>(
find.byKey(PrimaryIconCTA.animatedBuilderKey),
)
.animation as AnimationController;

final center = tester.getCenter(find.byType(PrimaryIconCTA));
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await gesture.moveTo(center);
expect(animationController.status, AnimationStatus.forward);
await tester.pumpAndSettle();
expect(animationController.status, AnimationStatus.completed);
await gesture.moveTo(Offset(1000, 1000));
expect(animationController.status, AnimationStatus.reverse);
await tester.pumpAndSettle();
expect(animationController.status, AnimationStatus.dismissed);
});
});
}
4 changes: 2 additions & 2 deletions test/home/widgets/welcome_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ void main() {
final l10n = tester.element(find.byType(WelcomeView)).l10n;

expect(find.text(l10n.initialScreenTitle), findsOneWidget);
expect(find.byType(PrimaryCTA), findsOneWidget);
expect(find.byType(PrimaryIconCTA), findsOneWidget);
});

testWidgets('animates in when enter', (tester) async {
Expand Down Expand Up @@ -74,7 +74,7 @@ void main() {
);

await tester.pumpAndSettle();
await tester.tap(find.byType(PrimaryCTA));
await tester.tap(find.byType(PrimaryIconCTA));

verify(() => homeBloc.add(const FromWelcomeToQuestion())).called(1);
});
Expand Down

0 comments on commit be2c7e1

Please sign in to comment.