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

feat: CTA animation #61

Merged
merged 7 commits into from
Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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