animated_currency_field 0.5.0 copy "animated_currency_field: ^0.5.0" to clipboard
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.

example/lib/main.dart

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()],
                  ),
                ),
        ),
      ),
    );
  }
}
1
likes
160
points
42
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#textfield #animation #input #currency #ui

License

MIT (license)

Dependencies

flutter

More

Packages that depend on animated_currency_field