Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Created December 24, 2025 19:55
Show Gist options
  • Select an option

  • Save slightfoot/627c6e480adfb0fc1190aea816eff6fe to your computer and use it in GitHub Desktop.

Select an option

Save slightfoot/627c6e480adfb0fc1190aea816eff6fe to your computer and use it in GitHub Desktop.
Snow Fall - by Simon Lightfoot :: #HumpdayQandA Christmas Special on 24th November 2025 :: https://www.youtube.com/watch?v=GpDVJvLhPec
// MIT License
//
// Copyright (c) 2025 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(const App());
}
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
backgroundColor: const Color(0xFF1a1a2e),
body: SnowFall(
numberOfFlakes: 150,
child: LoginScreenContent(),
),
),
);
}
}
// Snowfall Widget
class SnowFall extends StatefulWidget {
const SnowFall({
super.key,
required this.numberOfFlakes,
required this.child,
});
final int numberOfFlakes;
final Widget child;
@override
State<SnowFall> createState() => _SnowFallState();
}
class _SnowFallState extends State<SnowFall> with SingleTickerProviderStateMixin {
final Map<String, SnowSurface> _surfaces = {};
void registerSurface(String id, BuildContext context, {required double maxSnowDepth}) {
_surfaces[id] = SnowSurface(
id: id,
context: context,
maxSnowDepth: maxSnowDepth,
);
}
void unregisterSurface(String id) {
_surfaces.remove(id);
}
@override
Widget build(BuildContext context) {
return FallingSnow(
numberOfFlakes: widget.numberOfFlakes,
vsync: this,
surfaces: _surfaces,
child: widget.child,
);
}
}
class FallingSnow extends SingleChildRenderObjectWidget {
const FallingSnow({
super.key,
this.numberOfFlakes = 50,
this.minSize = 1.0,
this.maxSize = 6.0,
this.minSpeed = 1.0,
this.maxSpeed = 3.0,
required this.vsync,
required this.surfaces,
required Widget child,
}) : super(child: child);
final int numberOfFlakes;
final double minSize;
final double maxSize;
final double minSpeed;
final double maxSpeed;
final TickerProvider vsync;
final Map<String, SnowSurface> surfaces;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderFallingSnow(
numberOfFlakes: numberOfFlakes,
minSize: minSize,
maxSize: maxSize,
minSpeed: minSpeed,
maxSpeed: maxSpeed,
surfaces: surfaces,
vsync: vsync,
);
}
@override
void updateRenderObject(BuildContext context, RenderFallingSnow renderObject) {
renderObject
..numberOfFlakes = numberOfFlakes
..minSize = minSize
..maxSize = maxSize
..minSpeed = minSpeed
..maxSpeed = maxSpeed
..surfaces = surfaces;
}
}
/// Widget that registers its position with the parent FallingSnow widget
/// to allow snow to accumulate on it
class SnowCatcher extends StatefulWidget {
const SnowCatcher({
super.key,
required this.id,
required this.child,
this.maxSnowDepth = 20.0,
});
final String id;
final Widget child;
final double maxSnowDepth;
@override
State<SnowCatcher> createState() => _SnowCatcherState();
}
class _SnowCatcherState extends State<SnowCatcher> {
late _SnowFallState _snowFallState;
void _registerWithSnowRenderer() {
_snowFallState = context.findAncestorStateOfType<_SnowFallState>()!;
_snowFallState.registerSurface(
widget.id,
context,
maxSnowDepth: widget.maxSnowDepth,
);
}
void _unregisterWithSnowRenderer(String id) {
_snowFallState.unregisterSurface(id);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_registerWithSnowRenderer();
}
@override
void didUpdateWidget(SnowCatcher oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.id != widget.id) {
_unregisterWithSnowRenderer(oldWidget.id);
_registerWithSnowRenderer();
}
}
@override
void dispose() {
_unregisterWithSnowRenderer(widget.id);
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
class Snowflake {
Snowflake({
required this.x,
required this.y,
required this.size,
required this.speed,
required this.swingOffset,
required this.swingAmount,
required this.swingSpeed,
});
double x; // Horizontal position (0.0 to 1.0)
double y; // Vertical position (0.0 to 1.0)
final double size;
final double speed;
final double swingOffset;
final double swingAmount;
final double swingSpeed;
double time = 0.0; // Track time for sine wave
bool isLanded = false; // Whether the snowflake has landed
Offset? landedPosition; // Absolute position where it landed
}
class SnowSurface {
SnowSurface({
required this.id,
required this.context,
this.maxSnowDepth = 20.0,
});
final String id;
final BuildContext context;
final double maxSnowDepth;
final List<Snowflake> accumulatedSnow = [];
bool get isFull => accumulatedSnow.length >= maxSnowDepth;
}
class RenderFallingSnow extends RenderProxyBox {
RenderFallingSnow({
required int numberOfFlakes,
required double minSize,
required double maxSize,
required double minSpeed,
required double maxSpeed,
required Map<String, SnowSurface> surfaces,
required TickerProvider vsync,
}) : _numberOfFlakes = numberOfFlakes,
_minSize = minSize,
_maxSize = maxSize,
_minSpeed = minSpeed,
_maxSpeed = maxSpeed,
_surfaces = surfaces {
_initializeSnowflakes();
_controller = AnimationController(
vsync: vsync,
duration: const Duration(days: 1),
)..repeat();
_controller.addListener(markNeedsPaint);
}
late AnimationController _controller;
late List<Snowflake> _snowflakes;
final Random _random = Random();
int _numberOfFlakes;
double _minSize;
double _maxSize;
double _minSpeed;
double _maxSpeed;
Map<String, SnowSurface> _surfaces;
int get numberOfFlakes => _numberOfFlakes;
set numberOfFlakes(int value) {
if (_numberOfFlakes == value) return;
_numberOfFlakes = value;
_initializeSnowflakes();
markNeedsPaint();
}
double get minSize => _minSize;
set minSize(double value) {
if (_minSize == value) return;
_minSize = value;
_initializeSnowflakes();
markNeedsPaint();
}
double get maxSize => _maxSize;
set maxSize(double value) {
if (_maxSize == value) return;
_maxSize = value;
markNeedsPaint();
}
double get minSpeed => _minSpeed;
set minSpeed(double value) {
if (_minSpeed == value) return;
_minSpeed = value;
_initializeSnowflakes();
markNeedsPaint();
}
double get maxSpeed => _maxSpeed;
set maxSpeed(double value) {
if (_maxSpeed == value) return;
_maxSpeed = value;
_initializeSnowflakes();
markNeedsPaint();
}
Map<String, SnowSurface> get surfaces => _surfaces;
set surfaces(Map<String, SnowSurface> value) {
if (_surfaces == value) return;
_surfaces = value;
markNeedsPaint();
}
void _initializeSnowflakes() {
_snowflakes = List.generate(
_numberOfFlakes,
(index) => _createSnowflake(),
);
}
Snowflake _createSnowflake({double? y}) {
return Snowflake(
x: _random.nextDouble(),
y: y ?? _random.nextDouble(),
size: _minSize + _random.nextDouble() * (_maxSize - _minSize),
speed: _minSpeed + _random.nextDouble() * (_maxSpeed - _minSpeed),
swingOffset: _random.nextDouble() * 2 * pi,
swingAmount: _random.nextDouble() * 0.05 + 0.02,
swingSpeed: _random.nextDouble() * 2.0 + 1.0,
);
}
@override
void paint(PaintingContext context, Offset offset) {
// Paint child first
if (child != null) {
context.paintChild(child!, offset);
}
// Paint snowflakes on top
final canvas = context.canvas;
canvas.save();
canvas.translate(offset.dx, offset.dy);
final paint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
for (final flake in List.of(_snowflakes)) {
if (flake.isLanded && flake.landedPosition != null) {
// Draw landed snowflake at its fixed position
paint.color = Colors.white.withValues(alpha: (flake.size / _maxSize));
canvas.drawCircle(
flake.landedPosition!,
flake.size,
paint,
);
continue;
}
// Update time for sine wave
flake.time += flake.swingSpeed * 0.02;
// Update vertical position
flake.y += flake.speed * 0.001;
// Update horizontal position with sine wave
flake.x += sin(flake.time + flake.swingOffset) * flake.swingAmount * 0.02;
// Calculate absolute position
final x = flake.x * size.width;
final y = flake.y * size.height;
final flakePos = Offset(x, y);
// Check collision with surfaces
bool landed = false;
for (final surface in _surfaces.values) {
if (surface.isFull) continue;
// First check collision with accumulated snowflakes on this surface
bool landedOnSnow = false;
for (final landedFlake in surface.accumulatedSnow) {
if (landedFlake.landedPosition != null) {
if (_checkSnowflakeCollision(
flakePos,
flake.size,
landedFlake.landedPosition!,
landedFlake.size,
)) {
// Land on top of another snowflake
flake.isLanded = true;
flake.landedPosition = flakePos;
surface.accumulatedSnow.add(flake);
landedOnSnow = true;
landed = true;
break;
}
}
}
if (landedOnSnow) {
// Check if surface just became full
if (surface.isFull) {
// Release all accumulated snowflakes
for (final landedFlake in surface.accumulatedSnow) {
// Convert landed position back to normalized coordinates
if (landedFlake.landedPosition != null) {
landedFlake.x = landedFlake.landedPosition!.dx / size.width;
landedFlake.y = landedFlake.landedPosition!.dy / size.height;
}
// Reset the flake to continue falling
landedFlake.isLanded = false;
landedFlake.landedPosition = null;
landedFlake.time = 0.0;
}
// Clear the surface
surface.accumulatedSnow.clear();
}
break;
}
// If not landed on snow, check collision with the surface itself
if (_checkCollision(flakePos, flake.size, surface)) {
// Land on this surface
flake.isLanded = true;
flake.landedPosition = flakePos;
surface.accumulatedSnow.add(flake);
//print('flake landed on ${surface.id} '
// '${surface.accumulatedSnow.length} of ${surface.maxSnowDepth}');
landed = true;
// Check if surface just became full
if (surface.isFull) {
// Release all accumulated snowflakes
for (final landedFlake in surface.accumulatedSnow) {
// Convert landed position back to normalized coordinates
if (landedFlake.landedPosition != null) {
landedFlake.x = landedFlake.landedPosition!.dx / size.width;
landedFlake.y = landedFlake.landedPosition!.dy / size.height;
}
// Reset the flake to continue falling
landedFlake.isLanded = false;
landedFlake.landedPosition = null;
landedFlake.time = 0.0;
}
// Clear the surface
surface.accumulatedSnow.clear();
}
break;
}
}
if (!landed) {
// Reset snowflake when it goes off screen
if (flake.y > 1.0) {
flake.y = -0.05;
flake.x = _random.nextDouble();
flake.time = 0.0;
}
// Wrap around horizontally
if (flake.x < 0.0) {
flake.x = 1.0;
} else if (flake.x > 1.0) {
flake.x = 0.0;
}
}
// Draw falling snowflake
paint.color = Colors.white.withValues(alpha: (flake.size / _maxSize));
canvas.drawCircle(
flakePos,
flake.size,
paint,
);
}
canvas.restore();
}
bool _checkSnowflakeCollision(
Offset fallingFlakePos,
double fallingFlakeSize,
Offset landedFlakePos,
double landedFlakeSize,
) {
// Check if falling snowflake is touching the top of a landed snowflake
final fallingBottom = fallingFlakePos.dy + fallingFlakeSize;
final landedTop = landedFlakePos.dy - landedFlakeSize;
// Calculate horizontal distance between centers
final horizontalDistance = (fallingFlakePos.dx - landedFlakePos.dx).abs();
// Check if horizontally close enough (within combined radius)
final combinedRadius = fallingFlakeSize + landedFlakeSize;
if (horizontalDistance <= combinedRadius) {
// Check if vertically touching or just past
if (fallingBottom >= landedTop && fallingBottom <= landedTop + 5) {
return true;
}
}
return false;
}
bool _checkCollision(Offset flakePos, double flakeSize, SnowSurface surface) {
final renderBox = surface.context.findRenderObject() as RenderBox?;
if (renderBox == null || !renderBox.hasSize) {
return false;
}
// Get position relative to the RenderFallingSnow
final position = renderBox.localToGlobal(Offset.zero, ancestor: this);
final rect = position & renderBox.size;
// Check if snowflake is touching the top of the surface
final flakeBottom = flakePos.dy + flakeSize;
final surfaceTop = rect.top;
// Check if horizontally aligned with surface
if (flakePos.dx >= rect.left && flakePos.dx <= rect.right) {
// Check if touching or just past the surface
if (flakeBottom >= surfaceTop && flakeBottom <= surfaceTop + 5) {
return true;
}
}
return false;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
// Login Screen Content
class LoginScreenContent extends StatefulWidget {
const LoginScreenContent({super.key});
@override
State<LoginScreenContent> createState() => _LoginScreenContentState();
}
class _LoginScreenContentState extends State<LoginScreenContent> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _rememberMe = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
decoration: BoxDecoration(
color: const Color(0xFF2d2d44).withOpacity(0.95),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Colors.white.withOpacity(0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 30,
offset: const Offset(0, 10),
),
],
),
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Title
Center(
child: const Text(
'Welcome Back',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
const SizedBox(height: 8),
Center(
child: Text(
'Sign in to continue',
style: TextStyle(
fontSize: 16,
color: Colors.grey[400],
),
),
),
const SizedBox(height: 32),
// Email Field
SnowCatcher(
id: 'email_field',
maxSnowDepth: 15.0,
child: TextField(
controller: _emailController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Email',
labelStyle: TextStyle(color: Colors.grey[400]),
hintText: 'Enter your email',
hintStyle: TextStyle(color: Colors.grey[600]),
prefixIcon: Icon(Icons.email_outlined, color: Colors.grey[400]),
filled: true,
fillColor: Colors.white.withOpacity(0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.white.withOpacity(0.1),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Color(0xFF667eea),
width: 2,
),
),
),
keyboardType: TextInputType.emailAddress,
),
),
const SizedBox(height: 16),
// Password Field
SnowCatcher(
id: 'password_field',
maxSnowDepth: 15.0,
child: TextField(
controller: _passwordController,
obscureText: _obscurePassword,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'Password',
labelStyle: TextStyle(color: Colors.grey[400]),
hintText: 'Enter your password',
hintStyle: TextStyle(color: Colors.grey[600]),
prefixIcon: Icon(Icons.lock_outline, color: Colors.grey[400]),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: Colors.grey[400],
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
filled: true,
fillColor: Colors.white.withOpacity(0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.white.withOpacity(0.1),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Color(0xFF667eea),
width: 2,
),
),
),
),
),
const SizedBox(height: 16),
// Remember Me & Forgot Password
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Checkbox(
value: _rememberMe,
onChanged: (value) {
setState(() {
_rememberMe = value ?? false;
});
},
activeColor: const Color(0xFF667eea),
checkColor: Colors.white,
side: BorderSide(color: Colors.grey[600]!),
),
const Text(
'Remember me',
style: TextStyle(color: Colors.white),
),
],
),
TextButton(
onPressed: () {},
child: const Text(
'Forgot Password?',
style: TextStyle(
color: Color(0xFF667eea),
),
),
),
],
),
const SizedBox(height: 24),
// Login Button
SnowCatcher(
id: 'sign_in_button',
maxSnowDepth: 25.0,
child: ElevatedButton(
onPressed: () {
// Handle login
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Login button pressed!'),
duration: Duration(seconds: 2),
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF667eea),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: const Text(
'Sign In',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
// Divider
Row(
children: [
Expanded(
child: Divider(color: Colors.white.withOpacity(0.2)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'OR',
style: TextStyle(color: Colors.grey[500]),
),
),
Expanded(
child: Divider(color: Colors.white.withOpacity(0.2)),
),
],
),
const SizedBox(height: 16),
// Social Login Buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.g_mobiledata, size: 24),
label: const Text('Google'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(
color: Colors.white.withOpacity(0.2),
),
backgroundColor: Colors.white.withOpacity(0.05),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.apple, size: 24),
label: const Text('Apple'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(
color: Colors.white.withOpacity(0.2),
),
backgroundColor: Colors.white.withOpacity(0.05),
),
),
),
],
),
const SizedBox(height: 24),
// Sign Up Link
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Don't have an account? ",
style: TextStyle(color: Colors.grey[400]),
),
TextButton(
onPressed: () {},
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'Sign Up',
style: TextStyle(
color: Color(0xFF667eea),
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
),
),
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment