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

feat: thinking pulsing #68

Merged
merged 6 commits into from
Dec 7, 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
192 changes: 72 additions & 120 deletions lib/home/widgets/thinking_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ 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';
import 'package:phased/phased.dart';

class ThinkingView extends StatefulWidget {
const ThinkingView({super.key});
Expand All @@ -12,6 +11,8 @@ class ThinkingView extends StatefulWidget {
State<ThinkingView> createState() => ThinkingViewState();
}

const _thinkingDuration = Duration(milliseconds: 1500);

class ThinkingViewState extends State<ThinkingView>
with TickerProviderStateMixin, TransitionScreenMixin {
late Animation<double> _opacityIn;
Expand All @@ -32,12 +33,12 @@ class ThinkingViewState extends State<ThinkingView>

enterTransitionController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
duration: _thinkingDuration,
);

exitTransitionController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
duration: _thinkingDuration,
);
}

Expand Down Expand Up @@ -77,7 +78,7 @@ class ThinkingViewState extends State<ThinkingView>
opacity: _opacityIn,
child: FadeTransition(
opacity: _opacityOut,
child: const ThinkingAnimationView(),
child: const PulseAnimationView(),
),
),
Center(
Expand All @@ -101,57 +102,86 @@ class ThinkingViewState extends State<ThinkingView>
}
}

enum ThinkingAnimationPhase {
initial,
thinkingIn,
thinkingOut,
}

class ThinkingAnimationView extends StatefulWidget {
const ThinkingAnimationView({
super.key,
@visibleForTesting this.animationState,
});

final PhasedState<ThinkingAnimationPhase>? animationState;
class PulseAnimationView extends StatefulWidget {
const PulseAnimationView({super.key});

@override
State<ThinkingAnimationView> createState() => _ThinkingAnimationViewState();
State<PulseAnimationView> createState() => _PulseAnimationViewState();
}

class _ThinkingAnimationViewState extends State<ThinkingAnimationView> {
late final _state = widget.animationState ??
PhasedState<ThinkingAnimationPhase>(
values: ThinkingAnimationPhase.values,
initialValue: ThinkingAnimationPhase.initial,
);
const _pulseDuration = Duration(milliseconds: 2000);

class _PulseAnimationViewState extends State<PulseAnimationView>
with SingleTickerProviderStateMixin {
late AnimationController pulseTransitionController;
late Animation<double> _scale;

@override
Widget build(BuildContext context) {
return BlocListener<HomeBloc, HomeState>(
listener: (context, state) {
if (state.status == Status.thinkingToResults) {
_state.value = ThinkingAnimationPhase.thinkingOut;
}
},
child: ThinkingAnimation(
state: _state,
),
void initState() {
super.initState();

pulseTransitionController = AnimationController(
vsync: this,
duration: _pulseDuration,
);

_scale =
Tween<double>(begin: 1.05, end: .6).animate(pulseTransitionController);
pulseTransitionController.repeat(reverse: true);
}
}

class ThinkingAnimation extends Phased<ThinkingAnimationPhase> {
const ThinkingAnimation({
required super.state,
super.key,
});
@override
void dispose() {
pulseTransitionController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
const backgroundColor = Colors.transparent;
const borderColor = VertexColors.googleBlue;

return Align(
child: CirclesAnimation(
state: state,
child: LayoutBuilder(
builder: (context, constraints) {
final viewport = constraints.maxHeight < constraints.maxWidth
? constraints.maxHeight
: constraints.maxWidth;

final bigCircleRadius = viewport / 2;
final mediumCircleRadius = bigCircleRadius * .59;
final smallCircleRadius = bigCircleRadius * .27;

return SizedBox(
width: viewport,
height: viewport,
child: ScaleTransition(
scale: _scale,
child: Circle(
dotted: true,
backgroundColor: backgroundColor,
borderColor: borderColor,
radius: bigCircleRadius,
child: Center(
child: Circle(
dotted: true,
backgroundColor: backgroundColor,
borderColor: borderColor,
radius: mediumCircleRadius,
child: Center(
child: Circle(
dotted: true,
backgroundColor: backgroundColor,
borderColor: borderColor,
radius: smallCircleRadius,
),
),
),
),
),
),
);
},
),
);
}
Expand Down Expand Up @@ -193,81 +223,3 @@ class TextArea extends StatelessWidget {
);
}
}

class CirclesAnimation extends StatelessWidget {
const CirclesAnimation({
required this.state,
super.key,
});

final PhasedState<ThinkingAnimationPhase> state;

@override
Widget build(BuildContext context) {
const backgroundColor = Colors.transparent;
const borderColor = VertexColors.googleBlue;
const opacityDuration = Duration(milliseconds: 800);

return LayoutBuilder(
builder: (context, constraints) {
final viewport = constraints.maxHeight < constraints.maxWidth
? constraints.maxHeight
: constraints.maxWidth;

final bigCircleRadius = viewport / 2;
final mediumCircleRadius = bigCircleRadius * .59;
final smallCircleRadius = bigCircleRadius * .27;

const scaleDuration = Duration(seconds: 2);

return SizedBox(
width: viewport,
height: viewport,
child: AnimatedOpacity(
duration: opacityDuration,
opacity: state.phaseValue(
values: {
ThinkingAnimationPhase.initial: .8,
ThinkingAnimationPhase.thinkingOut: 0,
},
defaultValue: 1,
),
child: AnimatedScale(
duration: scaleDuration,
curve: Curves.decelerate,
scale: state.phaseValue(
values: {
ThinkingAnimationPhase.initial: .6,
ThinkingAnimationPhase.thinkingOut: .6,
},
defaultValue: 1.05,
),
child: Circle(
dotted: true,
backgroundColor: backgroundColor,
borderColor: borderColor,
radius: bigCircleRadius,
child: Center(
child: Circle(
dotted: true,
backgroundColor: backgroundColor,
borderColor: borderColor,
radius: mediumCircleRadius,
child: Center(
child: Circle(
dotted: true,
backgroundColor: backgroundColor,
borderColor: borderColor,
radius: smallCircleRadius,
),
),
),
),
),
),
),
);
},
);
}
}
31 changes: 6 additions & 25 deletions test/home/widgets/thinking_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ 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 'package:phased/phased.dart';

import '../../helpers/helpers.dart';

Expand All @@ -30,15 +29,15 @@ void main() {
testWidgets('renders correctly', (tester) async {
await tester.pumpApp(bootstrap());

expect(find.byType(CirclesAnimation), findsOneWidget);
expect(find.byType(PulseAnimationView), findsOneWidget);
expect(find.byType(TextArea), findsOneWidget);
});

testWidgets('renders correctly when in portrait mode', (tester) async {
tester.setViewSize(size: Size(600, 800));
await tester.pumpApp(bootstrap());

expect(find.byType(CirclesAnimation), findsOneWidget);
expect(find.byType(PulseAnimationView), findsOneWidget);
expect(find.byType(TextArea), findsOneWidget);
});

Expand Down Expand Up @@ -68,48 +67,30 @@ void main() {
expect(forwardExitStatuses, equals([Status.thinkingToResults]));
});

group('ThinkingAnimationView', () {
Widget bootstrap(PhasedState<ThinkingAnimationPhase> state) =>
BlocProvider.value(
group('PulseAnimationView', () {
Widget bootstrap() => BlocProvider.value(
value: homeBloc,
child: Material(
child: ThinkingAnimationView(
animationState: state,
),
child: PulseAnimationView(),
),
);

testWidgets(
'animation changes correctly',
(tester) async {
final animationState = PhasedState<ThinkingAnimationPhase>(
values: ThinkingAnimationPhase.values,
initialValue: ThinkingAnimationPhase.initial,
);
final streamController = StreamController<HomeState>();
whenListen(
homeBloc,
streamController.stream,
initialState: const HomeState(),
);

expect(animationState.value, equals(ThinkingAnimationPhase.initial));
await tester.pumpApp(bootstrap(animationState));

expect(
animationState.value,
equals(ThinkingAnimationPhase.thinkingIn),
);
await tester.pumpApp(bootstrap());

streamController.add(
const HomeState(status: Status.thinkingToResults),
);
await tester.pump();

expect(
animationState.value,
equals(ThinkingAnimationPhase.thinkingOut),
);
},
);
});
Expand Down