Skip to content

Instantly share code, notes, and snippets.

@aloisdeniel
Created April 23, 2022 14:57
Show Gist options
  • Save aloisdeniel/1c6ebcd140a168d77776903101ce8fc9 to your computer and use it in GitHub Desktop.
Save aloisdeniel/1c6ebcd140a168d77776903101ce8fc9 to your computer and use it in GitHub Desktop.
BastiUI - Challenge
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
abstract class Colors {
static const bg1 = Color(0xFF00232B);
static const content1 = Color(0xFFFFFFFF);
static const content2 = Color(0xAAFFFFFF);
static const shadow3d1 = Color(0xFF000000);
static const shadow3d2 = Color(0x00FFFFFF);
static const reallySad = Color(0xFFFF603E);
static const sad = Color(0xFFFF833E);
static const neutral = Color(0xFFFFC061);
static const happy = Color(0xFFFFD43E);
static const reallyHappy = Color(0xFFFFED4E);
}
void main() {
runApp(const App());
}
class App extends StatelessWidget {
const App({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: DecoratedBox(
decoration: const BoxDecoration(
color: Colors.bg1,
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Window(
width: 480,
height: 180,
child: Picker(),
),
const SizedBox(height: 48),
RichText(
text: TextSpan(
style: const TextStyle(
fontSize: 13,
color: Colors.content1,
decoration: TextDecoration.none,
),
children: [
const TextSpan(
text: 'Design by ',
style: TextStyle(color: Colors.content2),
),
TextSpan(
text: 'BastiUI',
recognizer: TapGestureRecognizer()
..onTap = () => print('https://basti.fr/basti-ui'),
),
],
),
),
RichText(
text: TextSpan(
style: const TextStyle(
fontSize: 13,
color: Colors.content1,
decoration: TextDecoration.none,
),
children: [
const TextSpan(
text: 'Implementation by ',
style: TextStyle(color: Colors.content2),
),
TextSpan(
text: 'Aloïs Deniel',
recognizer: TapGestureRecognizer()
..onTap = () => print('https://aloisdeniel.com'),
),
],
),
),
],
),
),
),
);
}
}
class Window extends StatelessWidget {
const Window({
Key? key,
required this.child,
this.width,
this.height,
}) : super(key: key);
final Widget child;
final double? width;
final double? height;
@override
Widget build(BuildContext context) {
final windowDecoration = BoxDecoration(
color: Colors.bg1,
border: Border.all(
color: Colors.content1,
width: 4,
),
);
return Stack(
children: [
Positioned.fill(
child: Container(
transform: Matrix4.translationValues(-18, 14, 0),
padding: const EdgeInsets.all(48),
decoration: windowDecoration,
),
),
Container(
width: 480,
height: 180,
padding: const EdgeInsets.all(48),
decoration: windowDecoration,
child: const Picker(),
),
],
);
}
}
class Picker extends StatefulWidget {
const Picker({
Key? key,
this.selected,
this.onSelectedChanged,
}) : super(key: key);
final FaceMode? selected;
final ValueChanged<FaceMode?>? onSelectedChanged;
@override
State<Picker> createState() => _PickerState();
}
class _PickerState extends State<Picker> {
FaceMode? selected;
void select(FaceMode mode) {
if (mode != selected) {
setState(() {
selected = mode;
});
widget.onSelectedChanged?.call(mode);
}
}
void deselect() {
if (selected != null) {
setState(() {
selected = null;
});
widget.onSelectedChanged?.call(null);
}
}
@override
void didUpdateWidget(covariant Picker oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.selected != selected) {
setState(() {
selected = widget.selected;
});
}
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Smiley(
mode: FaceMode.reallySad,
isSelected: selected == FaceMode.reallySad,
isEnabled: selected == null || selected == FaceMode.reallySad,
onSelected: select,
onDeselected: deselect,
),
Smiley(
mode: FaceMode.sad,
isSelected: selected == FaceMode.sad,
isEnabled: selected == null || selected == FaceMode.sad,
onSelected: select,
onDeselected: deselect,
),
Smiley(
mode: FaceMode.neutral,
isSelected: selected == FaceMode.neutral,
isEnabled: selected == null || selected == FaceMode.neutral,
onSelected: select,
onDeselected: deselect,
),
Smiley(
mode: FaceMode.happy,
isSelected: selected == FaceMode.happy,
isEnabled: selected == null || selected == FaceMode.happy,
onSelected: select,
onDeselected: deselect,
),
Smiley(
mode: FaceMode.reallyHappy,
isSelected: selected == FaceMode.reallyHappy,
isEnabled: selected == null || selected == FaceMode.reallyHappy,
onSelected: select,
onDeselected: deselect,
),
],
);
}
}
class Smiley extends StatefulWidget {
const Smiley({
Key? key,
required this.mode,
required this.onDeselected,
required this.onSelected,
this.isSelected = false,
this.isEnabled = true,
}) : super(key: key);
final bool isEnabled;
final bool isSelected;
final FaceMode mode;
final ValueChanged<FaceMode> onSelected;
final VoidCallback onDeselected;
@override
State<Smiley> createState() => _SmileyState();
}
class _SmileyState extends State<Smiley> {
bool _isHovered = false;
bool get isHighlighted => _isHovered || widget.isSelected;
Color get color {
switch (widget.mode) {
case FaceMode.reallySad:
return Colors.reallySad;
case FaceMode.sad:
return Colors.sad;
case FaceMode.neutral:
return Colors.neutral;
case FaceMode.happy:
return Colors.happy;
case FaceMode.reallyHappy:
return Colors.reallyHappy;
}
}
double get top {
switch (widget.mode) {
case FaceMode.reallySad:
return 35;
case FaceMode.sad:
return 30;
case FaceMode.neutral:
return 24;
case FaceMode.happy:
return 18;
case FaceMode.reallyHappy:
return 12;
}
}
double get right {
if (isHighlighted) return 8;
switch (widget.mode) {
case FaceMode.reallyHappy:
case FaceMode.reallySad:
return 4;
case FaceMode.happy:
case FaceMode.sad:
return 3;
case FaceMode.neutral:
return 1;
}
}
Duration get period {
switch (widget.mode) {
case FaceMode.reallySad:
return const Duration(milliseconds: 1600);
case FaceMode.sad:
return const Duration(milliseconds: 2400);
case FaceMode.neutral:
return const Duration(milliseconds: 3200);
case FaceMode.happy:
return const Duration(milliseconds: 4000);
case FaceMode.reallyHappy:
return const Duration(milliseconds: 4800);
}
}
@override
Widget build(BuildContext context) {
const selectBorderColor = Colors.content1;
const selectedSize = Head.size + 8 * 2;
return MouseRegion(
cursor: _isHovered ? SystemMouseCursors.click : MouseCursor.defer,
onHover: widget.isEnabled
? (_) {
if (!_isHovered) setState(() => _isHovered = true);
}
: null,
onExit: widget.isEnabled
? (_) {
if (_isHovered) setState(() => _isHovered = false);
}
: null,
child: GestureDetector(
onTap: widget.isEnabled
? () {
if (widget.isSelected) {
widget.onDeselected();
} else {
widget.onSelected(widget.mode);
}
}
: null,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: widget.isEnabled ? 1.0 : 0.5,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color:
selectBorderColor.withOpacity(widget.isSelected ? 1.0 : 0.0),
shape: BoxShape.circle,
),
width: widget.isSelected ? selectedSize : Head.size,
height: widget.isSelected ? selectedSize : Head.size,
alignment: Alignment.center,
child: Stack(
children: [
Head(
color: color,
hasShadow: isHighlighted,
),
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
right: right,
top: top,
child: Face(
mode: widget.mode,
period: period,
),
),
],
),
),
),
),
);
}
}
class Head extends StatelessWidget {
const Head({
Key? key,
required this.hasShadow,
required this.color,
}) : super(key: key);
final bool hasShadow;
final Color color;
static const double size = 56;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Container(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
width: size,
height: size,
),
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: hasShadow ? 1.0 : 0.0,
child: Stack(
children: [
Container(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
width: size,
height: size,
),
Container(
decoration: const BoxDecoration(
shape: BoxShape.circle,
backgroundBlendMode: BlendMode.overlay,
gradient: RadialGradient(
colors: [
Colors.shadow3d1,
Colors.shadow3d2,
],
stops: [0, 1],
center: Alignment.bottomLeft,
radius: 1.8,
),
),
width: size,
height: size,
),
],
),
)
],
);
}
}
enum FaceMode {
reallySad,
sad,
neutral,
happy,
reallyHappy,
}
class Face extends StatelessWidget {
const Face({
Key? key,
required this.mode,
this.color = Colors.bg1,
this.period = const Duration(seconds: 2),
}) : super(key: key);
final Color color;
final FaceMode mode;
final Duration period;
@override
Widget build(BuildContext context) {
return FittedBox(
fit: BoxFit.contain,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
BlinkingEyes(
period: period,
color: color,
eyeBrow: mode == FaceMode.reallySad,
),
Positioned(
bottom: () {
switch (mode) {
case FaceMode.reallyHappy:
return -8.0;
case FaceMode.happy:
return -8.0;
case FaceMode.neutral:
return -6.0;
case FaceMode.sad:
return -4.0;
case FaceMode.reallySad:
return -2.0;
}
}(),
child: Mouth(
color: color,
mode: () {
switch (mode) {
case FaceMode.reallyHappy:
return MouthMode.smilingOpened;
case FaceMode.happy:
return MouthMode.smiling;
case FaceMode.neutral:
return MouthMode.neutral;
case FaceMode.sad:
case FaceMode.reallySad:
return MouthMode.sad;
}
}(),
),
)
],
),
);
}
}
enum MouthMode {
sad,
neutral,
smiling,
smilingOpened,
}
class Mouth extends LeafRenderObjectWidget {
const Mouth({
Key? key,
required this.mode,
this.color = Colors.bg1,
}) : super(key: key);
final MouthMode mode;
final Color color;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderMouth(
color: color,
mode: mode,
);
}
@override
void updateRenderObject(BuildContext context, RenderMouth renderObject) {
renderObject
..color = color
..mode = mode;
}
}
class RenderMouth extends RenderBox {
RenderMouth({
required Color color,
required MouthMode mode,
}) : _color = color,
_mode = mode;
Color get color => _color;
Color _color;
set color(Color value) {
if (_color != value) {
_color = value;
markNeedsPaint();
}
}
MouthMode get mode => _mode;
MouthMode _mode;
set mode(MouthMode value) {
if (_mode != value) {
_mode = value;
markNeedsPaint();
}
}
@override
void performLayout() {
size = constraints.constrain(const Size(16, 8));
}
@override
void paint(PaintingContext context, Offset offset) {
final paint = Paint()..color = color;
context.canvas.save();
context.canvas.translate(offset.dx, offset.dy);
switch (mode) {
case MouthMode.smilingOpened:
final arc = Path();
arc.moveTo(0, 0);
arc.arcToPoint(
const Offset(16, 0),
radius: const Radius.circular(8),
clockwise: false,
);
arc.close();
context.canvas.drawPath(
arc,
paint,
);
break;
case MouthMode.smiling:
final arc = Path();
arc.moveTo(2, 0);
arc.arcToPoint(
const Offset(14, 0),
radius: const Radius.circular(6),
clockwise: false,
);
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 4;
context.canvas.drawPath(
arc,
paint,
);
break;
case MouthMode.neutral:
context.canvas.drawRect(
const Rect.fromLTWH(0, 2, 16, 4),
paint,
);
break;
case MouthMode.sad:
final arc = Path();
arc.moveTo(2, 8);
arc.arcToPoint(
const Offset(14, 8),
radius: const Radius.circular(6),
clockwise: true,
);
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 4;
context.canvas.drawPath(
arc,
paint,
);
break;
}
context.canvas.restore();
}
}
class BlinkingEyes extends StatefulWidget {
const BlinkingEyes({
Key? key,
required this.color,
this.eyeBrow = false,
this.period = const Duration(seconds: 2),
this.blinking = const Duration(milliseconds: 500),
}) : super(key: key);
final Color color;
final Duration period;
final bool eyeBrow;
final Duration blinking;
@override
State<BlinkingEyes> createState() => _BlinkingEyesState();
}
class _BlinkingEyesState extends State<BlinkingEyes>
with SingleTickerProviderStateMixin {
late final controller = AnimationController(
vsync: this,
duration: widget.period,
);
Animation<double> get openingAnimation => CurvedAnimation(
parent: controller,
curve: EyeCurve(widget.period, widget.blinking),
);
@override
void initState() {
super.initState();
controller.repeat();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: openingAnimation,
builder: (context, _) => Eyes(
color: widget.color,
eyeBrow: widget.eyeBrow,
opening: openingAnimation.value,
),
);
}
}
class EyeCurve extends Curve {
const EyeCurve(this.period, this.blinking);
final Duration period;
final Duration blinking;
@override
double transformInternal(double t) {
final blinkingDuration = blinking.inMilliseconds / period.inMilliseconds;
final startBlinking = 1.0 - blinkingDuration;
if (t >= 1 || t < startBlinking) {
return 1;
}
final relativeTime = (t - startBlinking) / blinkingDuration;
if (relativeTime < 0.5) {
return 1 - Curves.easeOut.transform(relativeTime * 2);
}
return Curves.easeIn.transform((relativeTime - 0.5) * 2);
}
}
class Eyes extends LeafRenderObjectWidget {
const Eyes({
Key? key,
this.color = Colors.bg1,
this.opening = 1,
this.eyeBrow = false,
}) : super(key: key);
final Color color;
final double opening;
final bool eyeBrow;
static const radius = 4.0;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderEyes(
color: color,
opening: opening,
eyeBrow: eyeBrow,
);
}
@override
void updateRenderObject(BuildContext context, RenderEyes renderObject) {
renderObject
..opening = opening
..eyeBrow = eyeBrow
..color = color;
}
}
class RenderEyes extends RenderBox {
RenderEyes(
{required Color color, required double opening, required bool eyeBrow})
: _opening = opening,
_eyeBrow = eyeBrow,
_color = color;
Color get color => _color;
Color _color;
set color(Color value) {
if (_color != value) {
_color = value;
markNeedsPaint();
}
}
bool get eyeBrow => _eyeBrow;
bool _eyeBrow;
set eyeBrow(bool value) {
if (_eyeBrow != value) {
_eyeBrow = value;
markNeedsPaint();
}
}
double get opening => _opening;
double _opening;
set opening(double value) {
if (_opening != value) {
_opening = value;
markNeedsPaint();
}
}
@override
void performLayout() {
size = constraints.constrain(const Size(40, Eyes.radius * 2));
}
@override
void paint(PaintingContext context, Offset offset) {
final paint = Paint()..color = color;
context.canvas.save();
context.canvas.translate(offset.dx, offset.dy);
if (opening < 1) {
context.canvas.clipRect(
Rect.fromCenter(
center: size.center(Offset.zero),
width: size.width,
height: Eyes.radius * 2 * opening,
),
);
}
final leftCenter = Offset(Eyes.radius, size.height / 2);
context.canvas.drawCircle(
leftCenter,
min(size.width, Eyes.radius),
paint,
);
final rightCenter = Offset(size.width - Eyes.radius, size.height / 2);
context.canvas.drawCircle(
rightCenter,
min(size.width, Eyes.radius),
paint,
);
context.canvas.restore();
if (eyeBrow) {
paint.style = PaintingStyle.stroke;
paint.strokeWidth = 4.0;
context.canvas.save();
context.canvas.translate(offset.dx, offset.dy);
final closing = 1 - opening;
context.canvas.drawLine(
leftCenter + Offset(5, -6 + 2 * closing),
leftCenter + Offset(-7 + 2 * closing, -9),
paint,
);
context.canvas.drawLine(
rightCenter + Offset(-5, -6 + 2 * closing),
rightCenter + Offset(7 - 2 * closing, -9),
paint,
);
context.canvas.restore();
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment