Created with <3 with dartpad.dev.
Created
April 1, 2023 19:31
-
-
Save rxlabz/084fcffd2bfb48519e723a7b45117673 to your computer and use it in GitHub Desktop.
obsidian-diamond-9881
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'dart:ui' as ui; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/physics.dart'; | |
const thumbSize = 52.0; | |
const minTemperature = 0.0; | |
const maxTemperature = 50.0; | |
void main() { | |
runApp(const App()); | |
} | |
class App extends StatelessWidget { | |
const App({Key? key}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return const MaterialApp(home: MainScreen()); | |
} | |
} | |
class MainScreen extends StatelessWidget { | |
const MainScreen({Key? key}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
backgroundColor: Colors.grey.shade100, | |
body: TemperatureView(), | |
); | |
} | |
} | |
class TemperatureView extends StatelessWidget { | |
final ValueNotifier<double> temperature = ValueNotifier(20.0); | |
TemperatureView({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
final textTheme = Theme.of(context).textTheme; | |
return Center( | |
child: SizedBox( | |
width: 400, | |
child: ValueListenableBuilder( | |
valueListenable: temperature, | |
builder: (context, value, _) { | |
return Column( | |
crossAxisAlignment: CrossAxisAlignment.stretch, | |
children: [ | |
Center( | |
child: Text( | |
' ${temperature.value.toInt()}°', | |
style: textTheme.displayLarge?.copyWith( | |
fontWeight: FontWeight.w500, color: Colors.grey[350]), | |
textAlign: TextAlign.center, | |
), | |
), | |
Expanded( | |
child: Stack( | |
children: [ | |
Positioned.fill( | |
child: Thermo(temperature: temperature.value)), | |
Positioned.fill( | |
left: 300, | |
child: CustomSlider( | |
value: temperature.value, | |
onValueChanged: (value) => temperature.value = value, | |
), | |
), | |
], | |
), | |
), | |
TemperatureIconBar( | |
value: value / maxTemperature, | |
onTemperatureChanged: (t) => temperature.value = t, | |
) | |
], | |
); | |
}, | |
), | |
), | |
); | |
} | |
} | |
class TemperatureIconBar extends StatelessWidget { | |
final double value; | |
final ValueChanged<double> onTemperatureChanged; | |
const TemperatureIconBar({ | |
super.key, | |
required this.value, | |
required this.onTemperatureChanged, | |
}); | |
@override | |
Widget build(BuildContext context) { | |
return Padding( | |
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 64), | |
child: Row( | |
mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
children: [ | |
ThermoIcon( | |
icon: Icons.ac_unit, | |
color: Colors.cyan, | |
selected: value < 1 / 3, | |
onTap: () => onTemperatureChanged(10), | |
), | |
ThermoIcon( | |
icon: Icons.water_drop, | |
color: Colors.amber, | |
selected: value >= 1 / 3 && value < 2 / 3, | |
onTap: () => onTemperatureChanged(20), | |
), | |
ThermoIcon( | |
icon: Icons.local_fire_department, | |
color: Colors.deepOrange, | |
selected: value >= 2 / 3, | |
onTap: () => onTemperatureChanged(35), | |
), | |
], | |
), | |
); | |
} | |
} | |
class ThermoIcon extends StatelessWidget { | |
final IconData icon; | |
final Color color; | |
final bool selected; | |
final VoidCallback onTap; | |
const ThermoIcon({ | |
required this.icon, | |
required this.color, | |
required this.onTap, | |
required this.selected, | |
super.key, | |
}); | |
@override | |
Widget build(BuildContext context) { | |
return GestureDetector( | |
onTap: onTap, | |
child: SizedBox( | |
width: 64, | |
height: 64, | |
child: Center( | |
child: AnimatedContainer( | |
duration: const Duration(milliseconds: 150), | |
width: selected ? 64 : 48, | |
height: selected ? 64 : 48, | |
decoration: BoxDecoration( | |
shape: BoxShape.circle, | |
color: selected ? color.withOpacity(.3) : Colors.grey.shade200, | |
), | |
child: Center( | |
child: Icon( | |
icon, | |
size: selected ? 52 : 32, | |
color: selected ? color : Colors.grey.shade300, | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
} | |
class Thermo extends StatefulWidget { | |
const Thermo({ | |
super.key, | |
required this.temperature, | |
}); | |
final double temperature; | |
@override | |
State<Thermo> createState() => _ThermoState(); | |
} | |
class _ThermoState extends State<Thermo> with SingleTickerProviderStateMixin { | |
late final AnimationController anim; | |
late final AnimationController impulseController; | |
final spring = SpringSimulation( | |
const SpringDescription(mass: 2, stiffness: 120, damping: 1), | |
0, | |
0.5, | |
1, | |
); | |
@override | |
void initState() { | |
super.initState(); | |
anim = AnimationController( | |
vsync: this, | |
duration: const Duration(milliseconds: 600), | |
); | |
anim.animateWith(spring); | |
} | |
@override | |
void didUpdateWidget(covariant Thermo oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (oldWidget.temperature != widget.temperature) { | |
anim.animateWith(spring); | |
} | |
} | |
@override | |
Widget build(BuildContext context) { | |
return AnimatedBuilder( | |
animation: anim, | |
builder: (context, _) { | |
return CustomPaint( | |
painter: ThermoPainter(widget.temperature, animation: anim.value), | |
); | |
}, | |
); | |
} | |
} | |
const thermoWidth = 96.0; | |
const vPadding = 20.0; | |
const thickness = 8.0; | |
const radius = thermoWidth / 2; | |
class ThermoPainter extends CustomPainter { | |
final double temperature; | |
final double animation; | |
ThermoPainter(this.temperature, {required this.animation}); | |
@override | |
void paint(Canvas canvas, Size size) { | |
//container | |
final topLeft = Offset( | |
(size.width - thermoWidth) / 2 + thickness, | |
vPadding + thickness, | |
); | |
final bottomRight = Offset( | |
(size.width + thermoWidth) / 2 - thickness, | |
size.height - vPadding - thickness, | |
); | |
_drawLiquid( | |
canvas, | |
size, | |
bottomRight, | |
topLeft, | |
offset: const Offset(25, 25), | |
blur: true, | |
); | |
drawContainer(canvas, topLeft, bottomRight); | |
_drawRules(size, canvas, temperature / maxTemperature); | |
// mask | |
_drawMask(canvas, size); | |
_drawLiquid(canvas, size, bottomRight, topLeft); | |
// white border | |
_drawContainerBorder(canvas, topLeft, bottomRight); | |
// reflets | |
_drawReflections(size, canvas); | |
} | |
void _drawContainerBorder( | |
ui.Canvas canvas, ui.Offset topLeft, ui.Offset bottomRight) { | |
return canvas.drawRRect( | |
RRect.fromRectAndRadius( | |
Rect.fromPoints(topLeft, bottomRight), | |
const Radius.circular(thermoWidth), | |
), | |
Paint() | |
..color = Colors.white | |
..style = PaintingStyle.stroke | |
..strokeWidth = 12, | |
); | |
} | |
void _drawMask(ui.Canvas canvas, ui.Size size) { | |
canvas.clipRRect( | |
RRect.fromRectAndRadius( | |
Rect.fromPoints( | |
Offset( | |
(size.width - thermoWidth) / 2 + thickness, vPadding + thickness), | |
Offset((size.width + thermoWidth) / 2 - thickness, | |
size.height - vPadding - thickness), | |
), | |
const Radius.circular(48), | |
), | |
); | |
} | |
void _drawReflections(ui.Size size, ui.Canvas canvas) { | |
var refletTopLeft1 = Offset( | |
size.width / 2, | |
vPadding + thickness + 30, | |
); | |
var refletBottomRight1 = Offset( | |
size.width / 2 + 28, | |
size.height - vPadding - thickness - 30, | |
); | |
canvas.drawRRect( | |
RRect.fromRectAndRadius( | |
Rect.fromPoints(refletTopLeft1, refletBottomRight1), | |
const Radius.circular(18), | |
), | |
Paint() | |
..shader = ui.Gradient.linear( | |
refletTopLeft1, | |
refletTopLeft1 + const Offset(30, 0), | |
[Colors.white10, Colors.white], | |
[0, 1], | |
) | |
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10), | |
); | |
var refletTopLeft2 = Offset( | |
size.width / 2 - 30, | |
vPadding + thickness + 30, | |
); | |
var refletBottomRight2 = Offset( | |
size.width / 2 - 12, | |
size.height - vPadding - thickness - 30, | |
); | |
canvas.drawRRect( | |
RRect.fromRectAndRadius( | |
Rect.fromPoints(refletTopLeft2, refletBottomRight2), | |
const Radius.circular(8), | |
), | |
Paint() | |
..shader = ui.Gradient.linear( | |
refletTopLeft2, | |
refletTopLeft2 + const Offset(20, 0), | |
[Colors.white10, Colors.white70], | |
[0, 1], | |
) | |
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8), | |
); | |
} | |
void _drawLiquid( | |
ui.Canvas canvas, | |
ui.Size size, | |
ui.Offset bottomRight, | |
ui.Offset topLeft, { | |
ui.Offset offset = Offset.zero, | |
bool blur = false, | |
}) { | |
const gradientColors = [ | |
Colors.red, | |
Colors.orange, | |
Colors.amber, | |
Colors.lightGreen, | |
Colors.cyan, | |
]; | |
final lightColors = gradientColors.map((e) => e.withOpacity(.5)).toList(); | |
// liquid back | |
final liquidTopLeft = Offset( | |
(size.width - thermoWidth) / 2 + thickness, | |
(size.height - radius - vPadding) - | |
((temperature / 50) * | |
(size.height - (2 * vPadding + radius * 2))), | |
) - | |
offset; | |
final r = Rect.fromPoints(liquidTopLeft, bottomRight - offset); | |
final colorStops = List.generate( | |
gradientColors.length, | |
(index) => index / gradientColors.length, | |
); | |
final pathLight = Path() | |
..moveTo(r.topLeft.dx, r.topLeft.dy - (30 * (1 - animation))) | |
..quadraticBezierTo( | |
r.topRight.dx - radius, | |
r.topRight.dy - (50 * (animation)), | |
r.topRight.dx, | |
r.topRight.dy - (30 * (animation)), | |
) | |
..lineTo(r.bottomRight.dx, r.bottomRight.dy) | |
..lineTo(r.bottomLeft.dx, r.bottomLeft.dy); | |
canvas.drawPath( | |
pathLight, | |
Paint() | |
..shader = ui.Gradient.linear( | |
topLeft, | |
bottomRight, | |
lightColors, | |
colorStops, | |
) | |
..maskFilter = MaskFilter.blur(BlurStyle.normal, blur ? 20 : 0), | |
); | |
// liquid | |
if (!blur) { | |
final path = Path() | |
..moveTo(r.topLeft.dx, r.topLeft.dy - (30 * animation)) | |
..quadraticBezierTo( | |
r.topRight.dx - radius, | |
r.topRight.dy, | |
r.topRight.dx, | |
r.topRight.dy - (30 * (1 - animation)), | |
) | |
..lineTo(r.bottomRight.dx, r.bottomRight.dy) | |
..lineTo(r.bottomLeft.dx, r.bottomLeft.dy); | |
canvas.drawPath( | |
path, | |
Paint() | |
..shader = ui.Gradient.linear( | |
topLeft, | |
bottomRight, | |
gradientColors, | |
colorStops, | |
), | |
); | |
} | |
} | |
void _drawRules(ui.Size size, ui.Canvas canvas, double ratio) { | |
final steps = List.generate(20, (index) => index); | |
for (final step in steps.skip(2)) { | |
final y = step * (size.height - 40) / 20; | |
canvas.drawLine( | |
Offset(340, y), | |
Offset(348, y), | |
Paint() | |
..color = y < (1 - ratio) * size.height | |
? Colors.grey.shade300 | |
: Colors.grey.shade400 | |
..style = PaintingStyle.stroke | |
..strokeWidth = 3, | |
); | |
} | |
} | |
void drawContainer( | |
ui.Canvas canvas, ui.Offset topLeft, ui.Offset bottomRight) { | |
canvas.drawRRect( | |
RRect.fromRectAndRadius( | |
Rect.fromPoints(topLeft, bottomRight), | |
const Radius.circular(48), | |
), | |
Paint()..color = Colors.grey.shade300, | |
); | |
} | |
@override | |
bool shouldRepaint(covariant ThermoPainter oldDelegate) { | |
return oldDelegate.temperature != temperature || | |
oldDelegate.animation != animation; | |
} | |
} | |
class CustomSlider extends StatelessWidget { | |
final double value; | |
final ValueChanged<double> onValueChanged; | |
const CustomSlider({ | |
required this.value, | |
required this.onValueChanged, | |
super.key, | |
}); | |
@override | |
Widget build(BuildContext context) { | |
return LayoutBuilder( | |
builder: (context, constraints) { | |
final refHeight = constraints.maxHeight - thumbSize * 1.8; | |
var ratio = 1 - value / maxTemperature; | |
final trackTop = refHeight * ratio; | |
final color = tween.transform(ratio)!; | |
return Stack( | |
children: [ | |
Positioned.fill( | |
top: 25, | |
child: CustomPaint( | |
painter: CustomSliderTrackShapePainter( | |
value: ratio, | |
color: color, | |
), | |
), | |
), | |
Positioned( | |
left: 16, | |
top: ui.clampDouble(trackTop, 30, refHeight /*- thumbSize*/), | |
child: CustomSliderThumb( | |
refHeight: refHeight, | |
trackTop: trackTop, | |
onValueChanged: onValueChanged, | |
color: color, | |
), | |
), | |
], | |
); | |
}, | |
); | |
} | |
} | |
class CustomSliderThumb extends StatefulWidget { | |
const CustomSliderThumb({ | |
super.key, | |
required this.refHeight, | |
required this.trackTop, | |
required this.onValueChanged, | |
required this.color, | |
}); | |
final double refHeight; | |
final double trackTop; | |
final ValueChanged<double> onValueChanged; | |
final ui.Color color; | |
@override | |
State<CustomSliderThumb> createState() => _CustomSliderThumbState(); | |
} | |
class _CustomSliderThumbState extends State<CustomSliderThumb> { | |
bool active = false; | |
@override | |
Widget build(BuildContext context) { | |
return GestureDetector( | |
onVerticalDragStart: (d) => setState(() => active = true), | |
onVerticalDragEnd: (d) => setState(() => active = false), | |
onVerticalDragUpdate: (d) { | |
final newValue = (widget.refHeight - widget.trackTop - d.delta.dy) / | |
widget.refHeight * | |
maxTemperature; | |
widget.onValueChanged(ui.clampDouble(newValue, 0, 50)); | |
}, | |
child: AnimatedContainer( | |
duration: const Duration(milliseconds: 250), | |
width: thumbSize, | |
height: thumbSize, | |
decoration: BoxDecoration( | |
shape: BoxShape.circle, | |
gradient: RadialGradient( | |
colors: [ | |
Colors.white70, | |
active ? widget.color : Colors.grey.shade300, | |
], | |
), | |
boxShadow: [ | |
BoxShadow( | |
offset: const Offset(3, 0), | |
blurRadius: 2, | |
spreadRadius: 1, | |
color: Colors.grey.shade400, | |
blurStyle: BlurStyle.inner, | |
), | |
], | |
), | |
), | |
); | |
} | |
} | |
final tween = TweenSequence<Color?>( | |
[ | |
TweenSequenceItem( | |
tween: ColorTween(begin: Colors.red, end: Colors.orange).chain( | |
CurveTween(curve: const Interval(0.0, 0.15, curve: Curves.linear))), | |
weight: 0.25, | |
), | |
TweenSequenceItem( | |
tween: ColorTween(begin: Colors.orange, end: Colors.amber).chain( | |
CurveTween(curve: const Interval(0.15, 0.3, curve: Curves.linear))), | |
weight: 0.25, | |
), | |
TweenSequenceItem( | |
tween: ColorTween(begin: Colors.amber, end: Colors.lightGreen).chain( | |
CurveTween(curve: const Interval(0.3, 0.5, curve: Curves.linear))), | |
weight: 0.25, | |
), | |
TweenSequenceItem( | |
tween: ColorTween(begin: Colors.lightGreen, end: Colors.cyan).chain( | |
CurveTween(curve: const Interval(0.5, .7, curve: Curves.linear))), | |
weight: 0.25, | |
), | |
], | |
); | |
class CustomSliderTrackShapePainter extends CustomPainter { | |
final double value; | |
final Color color; | |
CustomSliderTrackShapePainter({required this.value, required this.color}); | |
final thickness = 4.0; | |
@override | |
void paint(ui.Canvas canvas, ui.Size size) { | |
const marginRight = 24.0; | |
var refHeight = | |
(size.height - thumbSize + ((value - .35) * 14) - (value + .1) * 30); | |
final path = Path() | |
..moveTo(marginRight, 0) | |
..lineTo(marginRight, refHeight * (value) - 60) | |
..cubicTo( | |
marginRight, | |
refHeight * (value) - 20, | |
marginRight / 3, | |
refHeight * (value) - 40, | |
0, | |
max( | |
thumbSize / 2 + 10, | |
refHeight * (value), | |
), | |
) | |
..cubicTo( | |
0, | |
max(80, refHeight * (value) + 40), | |
marginRight, | |
max(60, refHeight * (value) + 30), | |
marginRight, | |
max(100, refHeight * (value) + 60), | |
) | |
..lineTo(marginRight, size.height) /*..lineTo(marginRight, 0)*/; | |
canvas.drawPath( | |
path, | |
Paint() | |
..color = color | |
..style = PaintingStyle.stroke | |
..strokeWidth = 4 | |
..shader = ui.Gradient.linear( | |
const Offset(0, 0), | |
Offset(0, size.height), | |
[Colors.grey.shade300.withOpacity(0), color, color.withOpacity(0)], | |
[0, ui.clampDouble(value, .1, .9), 1], | |
), | |
); | |
} | |
@override | |
bool shouldRepaint(covariant CustomPainter oldDelegate) { | |
return false; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment