animated_currency_field 0.5.0
animated_currency_field: ^0.5.0 copied to clipboard
A transparent Flutter text field where typed characters bloom from a blurry rising dot into the glyph. Tuned for amount entry, works for any text.
import 'package:animated_currency_field/animated_currency_field.dart';
import 'package:flutter/material.dart';
void main() => runApp(const DemoApp());
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Animated Amount Field',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF7C5CFF),
brightness: Brightness.dark,
),
useMaterial3: true,
fontFamily: 'SF Pro Display',
),
home: const DemoPage(),
);
}
}
class DemoPage extends StatefulWidget {
const DemoPage({super.key});
@override
State<DemoPage> createState() => _DemoPageState();
}
class _DemoPageState extends State<DemoPage> {
final _controller = TextEditingController();
double _speed = 1.0;
double _reverseSpeed = 1.0;
bool _linkReverseSpeed = true;
bool _allowDecimal = true;
bool _digitsOnly = true;
bool _showPrefix = true;
bool _showPlaceholder = true;
bool _showCursor = true;
Color _cursorColor = const Color(0xFFB39DFF);
String _prefixText = '\$';
double _dotSize = 16;
double _dotStartOpacity = 1.0;
double _dotTravelDistance = 0.35;
double _dotExpandScale = 2.4;
DotOrigin _dotOrigin = DotOrigin.bottom;
double _startBlur = 24;
double _glyphRiseDistance = 0.55;
bool _useCustomKeypad = false;
static const _glyphFontSize = 88.0;
static const _accent = Color(0xFFB39DFF);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _keypadInput(String c) {
final cur = _controller.value;
final candidate = TextEditingValue(
text: cur.text + c,
selection: TextSelection.collapsed(offset: cur.text.length + c.length),
);
final formatter = DecimalInputFormatter(
maxDecimalPlaces: _allowDecimal ? 2 : 0,
);
_controller.value = formatter.formatEditUpdate(cur, candidate);
}
void _keypadBackspace() {
final text = _controller.text;
if (text.isEmpty) return;
_controller.text = text.substring(0, text.length - 1);
}
AnimatedCharacterStyle get _characterStyle => AnimatedCharacterStyle(
dotSize: _dotSize,
dotStartOpacity: _dotStartOpacity,
dotTravelDistance: _dotTravelDistance,
dotExpandScale: _dotExpandScale,
dotOrigin: _dotOrigin,
startBlur: _startBlur,
glyphRiseDistance: _glyphRiseDistance,
);
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
backgroundColor: const Color(0xFF0B0820),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: false,
title: const Text(
'Animated Amount',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
actions: [
IconButton(
tooltip: 'Clear',
icon: const Icon(Icons.refresh_rounded),
onPressed: () => _controller.clear(),
),
const SizedBox(width: 4),
],
),
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment(-0.6, -0.9),
radius: 1.3,
colors: [Color(0xFF1F1547), Color(0xFF0B0820)],
stops: [0.0, 0.7],
),
),
child: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 40),
children: [
_HeroCard(
accent: _accent,
child: AnimatedCurrencyField(
controller: _controller,
autofocus: true,
useSystemKeyboard: !_useCustomKeypad,
showPlaceholder: _showPlaceholder,
placeholder: '0',
showPrefix: _showPrefix,
prefixText: _prefixText,
prefixStyle: const TextStyle(
fontSize: _glyphFontSize,
fontWeight: FontWeight.w500,
color: _accent,
height: 1.0,
),
placeholderStyle: TextStyle(
fontSize: _glyphFontSize,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.18),
height: 1.0,
),
digitsOnly: _digitsOnly,
allowDecimal: _allowDecimal,
maxDecimalPlaces: 2,
animationSpeed: _speed,
reverseAnimationSpeed:
_linkReverseSpeed ? null : _reverseSpeed,
showCursor: _showCursor,
cursorColor: _cursorColor,
characterStyle: _characterStyle,
textStyle: const TextStyle(
fontSize: _glyphFontSize,
fontWeight: FontWeight.w600,
color: Colors.white,
letterSpacing: -1.5,
height: 1.0,
),
),
),
const SizedBox(height: 20),
AnimatedSize(
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
child: _useCustomKeypad
? _Keypad(
accent: _accent,
allowDecimal: _allowDecimal,
onDigit: _keypadInput,
onDecimal: () => _keypadInput('.'),
onBackspace: _keypadBackspace,
)
: const SizedBox(width: double.infinity),
),
const SizedBox(height: 24),
_SectionCard(
title: 'Appearance',
icon: Icons.palette_outlined,
children: [
_ChipRow<String>(
label: 'Currency',
value: _prefixText,
options: const ['\$', '€', '₹', '¥'],
labelOf: (s) => s,
onChanged: (v) => setState(() => _prefixText = v),
),
const SizedBox(height: 16),
_ColorRow(
label: 'Cursor color',
value: _cursorColor,
colors: const [
Color(0xFFB39DFF),
Colors.white,
Color(0xFFFF7676),
Color(0xFF7CFFC4),
Color(0xFFFFC861),
],
onChanged: (c) => setState(() => _cursorColor = c),
),
],
),
const SizedBox(height: 16),
_SectionCard(
title: 'Animation',
icon: Icons.auto_awesome_outlined,
children: [
_SliderRow(
label: _linkReverseSpeed ? 'Speed' : 'Forward speed',
value: _speed,
min: 0.25,
max: 3.0,
divisions: 11,
onChanged: (v) => setState(() => _speed = v),
),
_Toggle(
label: 'Match reverse speed',
value: _linkReverseSpeed,
onChanged: (v) => setState(() => _linkReverseSpeed = v),
),
if (!_linkReverseSpeed)
_SliderRow(
label: 'Reverse speed',
value: _reverseSpeed,
min: 0.25,
max: 5.0,
divisions: 19,
onChanged: (v) => setState(() => _reverseSpeed = v),
),
_SliderRow(
label: 'Dot size',
value: _dotSize,
min: 4,
max: 40,
divisions: 36,
onChanged: (v) => setState(() => _dotSize = v),
),
_SliderRow(
label: 'Dot opacity',
value: _dotStartOpacity,
min: 0,
max: 1,
divisions: 20,
onChanged: (v) => setState(() => _dotStartOpacity = v),
),
_SliderRow(
label: 'Dot travel',
value: _dotTravelDistance,
min: 0,
max: 1.5,
divisions: 30,
onChanged: (v) => setState(() => _dotTravelDistance = v),
),
_SliderRow(
label: 'Dot expand',
value: _dotExpandScale,
min: 1.0,
max: 5.0,
divisions: 40,
onChanged: (v) => setState(() => _dotExpandScale = v),
),
_SliderRow(
label: 'Glyph rise',
value: _glyphRiseDistance,
min: 0,
max: 1.5,
divisions: 30,
onChanged: (v) => setState(() => _glyphRiseDistance = v),
),
_SliderRow(
label: 'Start blur',
value: _startBlur,
min: 0,
max: 50,
divisions: 25,
onChanged: (v) => setState(() => _startBlur = v),
),
const SizedBox(height: 8),
_ChipRow<DotOrigin>(
label: 'Dot origin',
value: _dotOrigin,
options: DotOrigin.values,
labelOf: (o) => o.name,
onChanged: (v) => setState(() => _dotOrigin = v),
),
],
),
const SizedBox(height: 16),
_SectionCard(
title: 'Behavior',
icon: Icons.tune_rounded,
children: [
_Toggle(
label: 'Use custom keypad',
value: _useCustomKeypad,
onChanged: (v) => setState(() => _useCustomKeypad = v),
),
_Toggle(
label: 'Digits only',
value: _digitsOnly,
onChanged: (v) => setState(() => _digitsOnly = v),
),
_Toggle(
label: 'Allow decimal',
value: _allowDecimal,
onChanged: (v) => setState(() => _allowDecimal = v),
),
_Toggle(
label: 'Show placeholder',
value: _showPlaceholder,
onChanged: (v) => setState(() => _showPlaceholder = v),
),
_Toggle(
label: 'Show prefix',
value: _showPrefix,
onChanged: (v) => setState(() => _showPrefix = v),
),
_Toggle(
label: 'Show cursor',
value: _showCursor,
onChanged: (v) => setState(() => _showCursor = v),
),
],
),
const SizedBox(height: 28),
FilledButton.icon(
onPressed: () => _controller.clear(),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: _accent.withValues(alpha: 0.18),
foregroundColor: _accent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: _accent.withValues(alpha: 0.3)),
),
),
icon: const Icon(Icons.delete_outline_rounded),
label: const Text(
'Clear amount',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
),
),
],
),
),
),
);
}
}
class _HeroCard extends StatelessWidget {
const _HeroCard({required this.child, required this.accent});
final Widget child;
final Color accent;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 36, 20, 32),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: 0.06),
Colors.white.withValues(alpha: 0.02),
],
),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
boxShadow: [
BoxShadow(
color: accent.withValues(alpha: 0.18),
blurRadius: 60,
spreadRadius: -10,
offset: const Offset(0, 20),
),
],
),
child: Column(
children: [
Text(
'Enter amount',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
letterSpacing: 1.4,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 24),
SizedBox(height: 110, child: child),
const SizedBox(height: 8),
Container(
height: 4,
width: 36,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
],
),
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.title,
required this.icon,
required this.children,
});
final String title;
final IconData icon;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, size: 18, color: Colors.white.withValues(alpha: 0.7)),
const SizedBox(width: 10),
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
color: Colors.white,
),
),
],
),
const SizedBox(height: 14),
...children,
],
),
);
}
}
class _SliderRow extends StatelessWidget {
const _SliderRow({
required this.label,
required this.value,
required this.min,
required this.max,
required this.divisions,
required this.onChanged,
});
final String label;
final double value;
final double min;
final double max;
final int divisions;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.75),
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
Text(
value.toStringAsFixed(2),
style: const TextStyle(
color: _DemoPageState._accent,
fontSize: 13,
fontWeight: FontWeight.w600,
fontFeatures: [FontFeature.tabularFigures()],
),
),
],
),
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
),
child: Slider(
min: min,
max: max,
divisions: divisions,
value: value,
onChanged: onChanged,
),
),
],
),
);
}
}
class _Toggle extends StatelessWidget {
const _Toggle({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
Switch.adaptive(
value: value,
onChanged: onChanged,
activeTrackColor: _DemoPageState._accent,
),
],
),
);
}
}
class _ChipRow<T> extends StatelessWidget {
const _ChipRow({
required this.label,
required this.value,
required this.options,
required this.labelOf,
required this.onChanged,
});
final String label;
final T value;
final List<T> options;
final String Function(T) labelOf;
final ValueChanged<T> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.75),
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final o in options)
ChoiceChip(
label: Text(labelOf(o)),
selected: value == o,
onSelected: (_) => onChanged(o),
showCheckmark: false,
labelStyle: TextStyle(
fontWeight: FontWeight.w600,
color: value == o ? Colors.white : Colors.white70,
),
backgroundColor: Colors.white.withValues(alpha: 0.05),
selectedColor: _DemoPageState._accent.withValues(alpha: 0.35),
side: BorderSide(
color: value == o
? _DemoPageState._accent
: Colors.white.withValues(alpha: 0.1),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
],
),
],
);
}
}
class _ColorRow extends StatelessWidget {
const _ColorRow({
required this.label,
required this.value,
required this.colors,
required this.onChanged,
});
final String label;
final Color value;
final List<Color> colors;
final ValueChanged<Color> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.75),
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 10),
Row(
children: [
for (final c in colors) ...[
GestureDetector(
onTap: () => onChanged(c),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 32,
height: 32,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: value == c
? Colors.white
: Colors.white.withValues(alpha: 0.15),
width: value == c ? 2.5 : 1,
),
boxShadow: value == c
? [
BoxShadow(
color: c.withValues(alpha: 0.5),
blurRadius: 12,
spreadRadius: -2,
),
]
: null,
),
),
),
const SizedBox(width: 10),
],
],
),
],
);
}
}
class _Keypad extends StatelessWidget {
const _Keypad({
required this.accent,
required this.allowDecimal,
required this.onDigit,
required this.onDecimal,
required this.onBackspace,
});
final Color accent;
final bool allowDecimal;
final ValueChanged<String> onDigit;
final VoidCallback onDecimal;
final VoidCallback onBackspace;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: Colors.white.withValues(alpha: 0.07)),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(color: accent, blurRadius: 8),
],
),
),
const SizedBox(width: 8),
const Text(
'Custom keypad',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white,
letterSpacing: 0.5,
),
),
],
),
Text(
'System keyboard off',
style: TextStyle(
fontSize: 11,
color: Colors.white.withValues(alpha: 0.5),
letterSpacing: 0.3,
),
),
],
),
const SizedBox(height: 14),
for (final row in const [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
])
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
for (final d in row) ...[
Expanded(
child: _KeypadButton(
label: d,
onTap: () => onDigit(d),
),
),
if (d != row.last) const SizedBox(width: 8),
],
],
),
),
Row(
children: [
Expanded(
child: _KeypadButton(
label: '.',
enabled: allowDecimal,
onTap: onDecimal,
),
),
const SizedBox(width: 8),
Expanded(
child: _KeypadButton(
label: '0',
onTap: () => onDigit('0'),
),
),
const SizedBox(width: 8),
Expanded(
child: _KeypadButton(
icon: Icons.backspace_outlined,
accent: accent,
onTap: onBackspace,
),
),
],
),
],
),
);
}
}
class _KeypadButton extends StatefulWidget {
const _KeypadButton({
this.label,
this.icon,
this.accent,
this.enabled = true,
required this.onTap,
});
final String? label;
final IconData? icon;
final Color? accent;
final bool enabled;
final VoidCallback onTap;
@override
State<_KeypadButton> createState() => _KeypadButtonState();
}
class _KeypadButtonState extends State<_KeypadButton> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
final disabled = !widget.enabled;
return GestureDetector(
onTapDown: disabled ? null : (_) => setState(() => _pressed = true),
onTapUp: disabled ? null : (_) => setState(() => _pressed = false),
onTapCancel: disabled ? null : () => setState(() => _pressed = false),
onTap: disabled ? null : widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 90),
height: 56,
decoration: BoxDecoration(
color: _pressed
? Colors.white.withValues(alpha: 0.14)
: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withValues(
alpha: _pressed ? 0.22 : 0.08,
),
),
),
child: Center(
child: widget.icon != null
? Icon(
widget.icon,
size: 22,
color: (widget.accent ?? Colors.white).withValues(
alpha: disabled ? 0.25 : 1.0,
),
)
: Text(
widget.label!,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(
alpha: disabled ? 0.2 : 0.95,
),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
),
);
}
}