tasq/lib/widgets/gemini_animated_text_field.dart

281 lines
7.9 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
// How far the glow bleeds outside the child's bounds on each side.
const _kGlowSpread = 12.0;
/// Wraps any widget with a soft outward Gemini-colored glow that animates
/// while [isProcessing] is true and disappears when false.
/// The glow paints *outside* the child's bounds without affecting layout.
class GeminiAnimatedBorder extends StatefulWidget {
final Widget child;
final bool isProcessing;
/// Must match the border-radius of the wrapped widget so the glow follows its shape.
final double borderRadius;
/// When true the glow uses DeepSeek blue tones instead of Gemini colours.
final bool useDeepSeekColors;
const GeminiAnimatedBorder({
super.key,
required this.child,
required this.isProcessing,
this.borderRadius = 8,
this.useDeepSeekColors = false,
});
@override
State<GeminiAnimatedBorder> createState() => _GeminiAnimatedBorderState();
}
class _GeminiAnimatedBorderState extends State<GeminiAnimatedBorder>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
);
if (widget.isProcessing) _controller.repeat();
}
@override
void didUpdateWidget(GeminiAnimatedBorder old) {
super.didUpdateWidget(old);
if (widget.isProcessing && !old.isProcessing) {
_controller.repeat();
} else if (!widget.isProcessing && old.isProcessing) {
_controller.stop();
_controller.reset();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.isProcessing) return widget.child;
return AnimatedBuilder(
animation: _controller,
child: widget.child,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
// Glow layer positioned to bleed outside the child on all sides.
Positioned(
left: -_kGlowSpread,
right: -_kGlowSpread,
top: -_kGlowSpread,
bottom: -_kGlowSpread,
child: CustomPaint(
painter: _GeminiGlowPainter(
rotation: _controller.value,
borderRadius: widget.borderRadius,
glowSpread: _kGlowSpread,
deepSeekMode: widget.useDeepSeekColors,
),
),
),
child!,
],
);
},
);
}
}
/// Wraps a TextField with a rotating gradient border that animates with Gemini colors
/// when processing is active. Shows normal border appearance when not processing.
class GeminiAnimatedTextField extends StatefulWidget {
final TextEditingController controller;
final String? labelText;
final int? maxLines;
final bool enabled;
final bool isProcessing;
final InputDecoration? decoration;
/// When true the glow uses DeepSeek blue tones instead of Gemini colours.
final bool useDeepSeekColors;
const GeminiAnimatedTextField({
super.key,
required this.controller,
this.labelText,
this.maxLines,
this.enabled = true,
this.isProcessing = false,
this.decoration,
this.useDeepSeekColors = false,
});
@override
State<GeminiAnimatedTextField> createState() =>
_GeminiAnimatedTextFieldState();
}
class _GeminiAnimatedTextFieldState extends State<GeminiAnimatedTextField>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
);
if (widget.isProcessing) _controller.repeat();
}
@override
void didUpdateWidget(GeminiAnimatedTextField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isProcessing && !oldWidget.isProcessing) {
_controller.repeat();
} else if (!widget.isProcessing && oldWidget.isProcessing) {
_controller.stop();
_controller.reset();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final field = TextField(
controller: widget.controller,
enabled: widget.enabled && !widget.isProcessing,
maxLines: widget.maxLines,
decoration:
widget.decoration ?? InputDecoration(labelText: widget.labelText),
);
if (!widget.isProcessing) return field;
return AnimatedBuilder(
animation: _controller,
child: field,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: -_kGlowSpread,
right: -_kGlowSpread,
top: -_kGlowSpread,
bottom: -_kGlowSpread,
child: CustomPaint(
painter: _GeminiGlowPainter(
rotation: _controller.value,
borderRadius: 4,
glowSpread: _kGlowSpread,
deepSeekMode: widget.useDeepSeekColors,
),
),
),
child!,
],
);
},
);
}
}
/// Paints a soft outward glow using layered blurred strokes.
/// The [size] passed to [paint] is LARGER than the child by [glowSpread] on
/// every side, so the rrect is inset by [glowSpread] to sit exactly on the
/// child's border, and the blur bleeds outward.
class _GeminiGlowPainter extends CustomPainter {
final double rotation;
final double borderRadius;
final double glowSpread;
final bool deepSeekMode;
// Gemini brand colors — closed loop for a seamless sweep.
static const _geminiColors = [
Color(0xFF4285F4), // Blue
Color(0xFFEA4335), // Red
Color(0xFFFBBC04), // Yellow
Color(0xFF34A853), // Green
Color(0xFF4285F4), // Blue (close loop)
];
static const _geminiStops = [0.0, 0.25, 0.5, 0.75, 1.0];
// DeepSeek brand colors — pure blue closed loop.
static const _deepSeekColors = [
Color(0xFF4D9BFF), // Sky blue
Color(0xFF1A56DB), // Deep blue
Color(0xFF00CFFF), // Cyan
Color(0xFF4D9BFF), // Sky blue (close loop)
];
static const _deepSeekStops = [0.0, 0.33, 0.66, 1.0];
const _GeminiGlowPainter({
required this.rotation,
required this.borderRadius,
required this.glowSpread,
this.deepSeekMode = false,
});
@override
void paint(Canvas canvas, Size size) {
// The rect that coincides with the actual child's outline.
final childRect = Rect.fromLTWH(
glowSpread,
glowSpread,
size.width - glowSpread * 2,
size.height - glowSpread * 2,
);
final rrect = RRect.fromRectAndRadius(
childRect,
Radius.circular(borderRadius),
);
final colors = deepSeekMode ? _deepSeekColors : _geminiColors;
final stops = deepSeekMode ? _deepSeekStops : _geminiStops;
final shader = SweepGradient(
colors: colors,
stops: stops,
transform: GradientRotation(rotation * 2 * math.pi),
).createShader(childRect);
// Outer glow — wide stroke + strong blur spreads the color outward.
canvas.drawRRect(
rrect,
Paint()
..shader = shader
..style = PaintingStyle.stroke
..strokeWidth = glowSpread * 1.6
..maskFilter = MaskFilter.blur(BlurStyle.normal, glowSpread),
);
// Inner glow — narrower, less blurred for a crisper halo near the border.
canvas.drawRRect(
rrect,
Paint()
..shader = shader
..style = PaintingStyle.stroke
..strokeWidth = glowSpread * 0.7
..maskFilter = MaskFilter.blur(BlurStyle.normal, glowSpread * 0.4),
);
}
@override
bool shouldRepaint(_GeminiGlowPainter old) =>
old.rotation != rotation || old.deepSeekMode != deepSeekMode;
}