virtual_keypad 1.0.1 copy "virtual_keypad: ^1.0.1" to clipboard
virtual_keypad: ^1.0.1 copied to clipboard

On-screen virtual keyboard widget and keypad for Flutter kiosk, POS, TV, desktop and touchscreen apps. Numeric, numpad, PIN, emoji, floating and custom layouts in 12 languages.

example/lib/main.dart

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:virtual_keypad/virtual_keypad.dart';

import 'demo_presets.dart';
import 'preview_page.dart';

void main() {
  // Registers the 12 built-in languages. Required before runApp.
  initializeKeyboardLayouts();
  runApp(const ExampleApp());
}

const _seed = Color(0xFF6C63FF);

/// Caps the reading column on a desktop or browser window. The keyboard is
/// deliberately left unconstrained, so it spans the full width like a system
/// keyboard does.
const _maxWidth = 980.0;

class ExampleApp extends StatefulWidget {
  const ExampleApp({super.key});

  @override
  State<ExampleApp> createState() => _ExampleAppState();
}

class _ExampleAppState extends State<ExampleApp> {
  bool _dark = false;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'virtual_keypad',
      debugShowCheckedModeBanner: false,
      themeMode: _dark ? ThemeMode.dark : ThemeMode.light,
      theme: _theme(Brightness.light),
      darkTheme: _theme(Brightness.dark),
      home: HomePage(
        dark: _dark,
        onDarkChanged: (value) => setState(() => _dark = value),
      ),
    );
  }

  ThemeData _theme(Brightness brightness) {
    final scheme = ColorScheme.fromSeed(
      seedColor: _seed,
      brightness: brightness,
    );
    return ThemeData(
      colorScheme: scheme,
      useMaterial3: true,
      scaffoldBackgroundColor: brightness == Brightness.light
          ? const Color(0xFFF6F6FA)
          : const Color(0xFF121216),
      cardTheme: CardThemeData(
        elevation: 0,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(18),
          side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
        ),
      ),
      chipTheme: ChipThemeData(
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
        side: BorderSide.none,
      ),
    );
  }
}

const _types = <String, KeyboardType>{
  'Text': KeyboardType.text,
  'Multiline': KeyboardType.multiline,
  'Number': KeyboardType.number,
  'Phone': KeyboardType.phone,
  'Email': KeyboardType.emailAddress,
  'URL': KeyboardType.url,
  'PIN pad': KeyboardType.custom,
};

class HomePage extends StatefulWidget {
  const HomePage({super.key, required this.dark, required this.onDarkChanged});

  final bool dark;
  final ValueChanged<bool> onDarkChanged;

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final _controller = TextEditingController();

  KeyboardType _type = KeyboardType.text;
  int _preset = 0;
  bool _emoji = true;
  bool _floating = false;
  bool _dpad = false;

  @override
  void initState() {
    super.initState();
    _controller.addListener(_repaint);
    // The active language lives on the provider, so the chips here follow a
    // change made in the preview gallery.
    KeyboardLayoutProvider.instance.addListener(_repaint);
  }

  @override
  void dispose() {
    KeyboardLayoutProvider.instance.removeListener(_repaint);
    _controller.dispose();
    super.dispose();
  }

  void _repaint() {
    if (mounted) setState(() {});
  }

  VirtualKeypadTheme get _keypadTheme => keypadPresets[_preset].theme;

  /// Read from the provider rather than held here, so switching language in
  /// the preview gallery is reflected when you come back.
  String get _language => KeyboardLayoutProvider.instance.currentLanguageCode;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;

    return Scaffold(
      appBar: AppBar(
        title: const Text(
          'virtual_keypad',
          style: TextStyle(fontWeight: FontWeight.w600),
        ),
        centerTitle: false,
        backgroundColor: Colors.transparent,
        surfaceTintColor: Colors.transparent,
        actions: [
          IconButton(
            tooltip: 'Preview gallery',
            icon: const Icon(Icons.grid_view_rounded),
            onPressed: () => Navigator.of(context).push(
              MaterialPageRoute<void>(builder: (_) => const PreviewPage()),
            ),
          ),
          IconButton(
            tooltip: widget.dark ? 'Switch to light' : 'Switch to dark',
            icon: Icon(
              widget.dark ? Icons.light_mode_rounded : Icons.dark_mode_rounded,
            ),
            onPressed: () => widget.onDarkChanged(!widget.dark),
          ),
          const SizedBox(width: 4),
        ],
      ),
      body: Column(
        children: [
          Expanded(child: _buildBody(scheme)),
          if (!_floating)
            DecoratedBox(
              decoration: BoxDecoration(
                border: Border(
                  top: BorderSide(
                    color: scheme.outlineVariant.withValues(alpha: 0.6),
                  ),
                ),
              ),
              child: _buildKeypad(),
            ),
        ],
      ),
    );
  }

  Widget _buildKeypad() {
    return VirtualKeypad(
      standalone: true,
      type: _type,
      customLayout: _type == KeyboardType.custom ? pinLayout : null,
      theme: _keypadTheme,
      enableEmojiKey: _emoji,
      enableDpadNavigation: _dpad,
      availableLanguages: allLanguages.map((l) => l.code).toList(),
      initialLanguage: _language,
      onLanguageChanged: (_) => setState(() {}),
    );
  }

  Widget _buildBody(ColorScheme scheme) {
    final content = ListView(
      padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
      children: [
        _Display(text: _controller.text, scheme: scheme),
        const SizedBox(height: 16),
        TextField(
          controller: _controller,
          maxLines: _type == KeyboardType.multiline ? 3 : 1,
          decoration: InputDecoration(
            filled: true,
            fillColor: scheme.surfaceContainerHighest.withValues(alpha: 0.4),
            border: OutlineInputBorder(
              borderRadius: BorderRadius.circular(14),
              borderSide: BorderSide.none,
            ),
            hintText: 'Tap here, then use the keyboard below',
            prefixIcon: const Icon(Icons.keyboard_rounded),
            suffixIcon: _controller.text.isEmpty
                ? null
                : IconButton(
                    icon: const Icon(Icons.close_rounded),
                    onPressed: _controller.clear,
                  ),
          ),
        ),
        const SizedBox(height: 20),
        _Section(
          icon: Icons.dialpad_rounded,
          title: 'Keyboard type',
          subtitle: 'The layout adapts to the field',
          child: _ChipRow(
            labels: _types.keys.toList(),
            isSelected: (label) => _types[label] == _type,
            onTap: (label) => setState(() => _type = _types[label]!),
          ),
        ),
        const SizedBox(height: 12),
        _Section(
          icon: Icons.translate_rounded,
          title: 'Language',
          subtitle:
              '${allLanguages.length} built in. Long-press the space bar to '
              'switch from the keyboard',
          child: _ChipRow(
            labels: allLanguages.map((l) => l.code).toList(),
            labelBuilder: languageLabel,
            isSelected: (code) => code == _language,
            onTap: (code) {
              KeyboardLayoutProvider.instance.setLanguage(code);
              setState(() {});
            },
          ),
        ),
        const SizedBox(height: 12),
        _Section(
          icon: Icons.palette_rounded,
          title: 'Keyboard theme',
          subtitle: 'Two built in, four built with VirtualKeypadTheme',
          child: _ChipRow(
            labels: keypadPresets.map((p) => p.name).toList(),
            isSelected: (name) => keypadPresets[_preset].name == name,
            onTap: (name) => setState(
              () => _preset = keypadPresets.indexWhere((p) => p.name == name),
            ),
          ),
        ),
        const SizedBox(height: 12),
        _Section(
          icon: Icons.tune_rounded,
          title: 'Options',
          child: Column(
            children: [
              _Toggle(
                icon: Icons.emoji_emotions_rounded,
                title: 'Emoji key',
                subtitle: 'Adds an emoji page to text layouts',
                value: _emoji,
                onChanged: (v) => setState(() => _emoji = v),
              ),
              _Toggle(
                icon: Icons.open_in_new_rounded,
                title: 'Floating mode',
                subtitle: 'Draggable panel instead of inline',
                value: _floating,
                onChanged: (v) => setState(() => _floating = v),
              ),
              _Toggle(
                icon: Icons.gamepad_rounded,
                title: 'D-pad navigation',
                subtitle: 'Arrow keys move, Enter presses. For TV',
                value: _dpad,
                onChanged: (v) => setState(() => _dpad = v),
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        const _EmojiFontStatus(),
      ],
    );

    // Keep the column readable on desktop and web instead of stretching it
    // across the full window.
    final constrained = Center(
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: _maxWidth),
        child: content,
      ),
    );

    if (!_floating) return constrained;

    // Floating mode takes the same configuration as the inline keyboard, so
    // every control on the page keeps working when it is switched on.
    return VirtualKeypadFloating(
      standalone: true,
      type: _type,
      customLayout: _type == KeyboardType.custom ? pinLayout : null,
      theme: _keypadTheme,
      enableEmojiKey: _emoji,
      enableDpadNavigation: _dpad,
      availableLanguages: allLanguages.map((l) => l.code).toList(),
      initialLanguage: _language,
      onLanguageChanged: (_) => setState(() {}),
      width: 380,
      borderRadius: 18,
      child: constrained,
    );
  }
}

/// Big read-out of what has been typed, so the keyboard's effect is obvious.
class _Display extends StatelessWidget {
  const _Display({required this.text, required this.scheme});

  final String text;
  final ColorScheme scheme;

  @override
  Widget build(BuildContext context) {
    final empty = text.isEmpty;

    return Container(
      width: double.infinity,
      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(20),
        gradient: LinearGradient(
          colors: [_seed, const Color(0xFF8B5CF6)],
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
        ),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              const Icon(Icons.bolt_rounded, color: Colors.white70, size: 16),
              const SizedBox(width: 6),
              Text(
                empty ? 'Nothing typed yet' : '${text.characters.length} chars',
                style: const TextStyle(
                  color: Colors.white70,
                  fontSize: 12,
                  fontWeight: FontWeight.w600,
                  letterSpacing: 0.4,
                ),
              ),
            ],
          ),
          const SizedBox(height: 10),
          Text(
            empty ? 'Your text appears here' : text,
            maxLines: 3,
            overflow: TextOverflow.ellipsis,
            style: TextStyle(
              color: empty ? Colors.white38 : Colors.white,
              fontSize: 22,
              fontWeight: FontWeight.w600,
              height: 1.3,
            ),
          ),
        ],
      ),
    );
  }
}

class _Section extends StatelessWidget {
  const _Section({
    required this.icon,
    required this.title,
    required this.child,
    this.subtitle,
  });

  final IconData icon;
  final String title;
  final String? subtitle;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;

    return Card(
      child: Padding(
        padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(icon, size: 18, color: scheme.primary),
                const SizedBox(width: 8),
                Text(
                  title,
                  style: const TextStyle(
                    fontWeight: FontWeight.w700,
                    fontSize: 15,
                  ),
                ),
              ],
            ),
            if (subtitle != null) ...[
              const SizedBox(height: 4),
              Text(
                subtitle!,
                style: TextStyle(fontSize: 12, color: scheme.outline),
              ),
            ],
            const SizedBox(height: 12),
            child,
          ],
        ),
      ),
    );
  }
}

class _ChipRow extends StatelessWidget {
  const _ChipRow({
    required this.labels,
    required this.isSelected,
    required this.onTap,
    this.labelBuilder,
  });

  final List<String> labels;
  final String Function(String)? labelBuilder;
  final bool Function(String) isSelected;
  final ValueChanged<String> onTap;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;

    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: [
        for (final label in labels)
          ChoiceChip(
            label: Text(labelBuilder?.call(label) ?? label),
            selected: isSelected(label),
            showCheckmark: false,
            labelStyle: TextStyle(
              fontSize: 13,
              fontWeight: FontWeight.w600,
              color: isSelected(label) ? scheme.onPrimary : scheme.onSurface,
            ),
            selectedColor: scheme.primary,
            backgroundColor: scheme.surfaceContainerHighest.withValues(
              alpha: 0.5,
            ),
            onSelected: (_) => onTap(label),
          ),
      ],
    );
  }
}

class _Toggle extends StatelessWidget {
  const _Toggle({
    required this.icon,
    required this.title,
    required this.subtitle,
    required this.value,
    required this.onChanged,
  });

  final IconData icon;
  final String title;
  final String subtitle;
  final bool value;
  final ValueChanged<bool> onChanged;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;

    return SwitchListTile(
      contentPadding: EdgeInsets.zero,
      visualDensity: VisualDensity.compact,
      secondary: Icon(
        icon,
        size: 20,
        color: value ? scheme.primary : scheme.outline,
      ),
      title: Text(
        title,
        style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
      ),
      subtitle: Text(
        subtitle,
        style: TextStyle(fontSize: 11.5, color: scheme.outline),
      ),
      value: value,
      onChanged: onChanged,
    );
  }
}

/// Reports which emoji font is in use, and why.
///
/// Web has no system emoji font, so the package bundles one and applies it
/// there only. This is the quickest way to confirm that works in a browser,
/// since `flutter test --platform chrome` is broken on Windows.
class _EmojiFontStatus extends StatelessWidget {
  const _EmojiFontStatus();

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    final style = defaultEmojiTextStyle();
    final ok = kIsWeb
        ? style?.fontFamily == kBundledEmojiFontFamily
        : style == null;

    final color = ok ? const Color(0xFF12A150) : scheme.error;

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(14),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Icon(
              ok ? Icons.check_circle_rounded : Icons.error_rounded,
              color: color,
              size: 20,
            ),
            const SizedBox(width: 10),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    kIsWeb
                        ? 'Web: bundled emoji font'
                        : 'Native: system emoji font',
                    style: const TextStyle(
                      fontWeight: FontWeight.w700,
                      fontSize: 13,
                    ),
                  ),
                  const SizedBox(height: 3),
                  Text(
                    kIsWeb
                        ? 'Monochrome, renders offline. Pass '
                              'colorEmojiFontLoader for colour.'
                        : 'Full colour from the OS, already works offline.',
                    style: TextStyle(fontSize: 11.5, color: scheme.outline),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}
48
likes
160
points
801
downloads
screenshot

Documentation

API reference

Publisher

verified publisheralmasum.dev

Weekly Downloads

On-screen virtual keyboard widget and keypad for Flutter kiosk, POS, TV, desktop and touchscreen apps. Numeric, numpad, PIN, emoji, floating and custom layouts in 12 languages.

Repository (GitHub)
View/report issues
Contributing

Topics

#keyboard #virtual-keyboard #onscreen-keyboard #multilingual-keyboard #text-input

License

MIT (license)

Dependencies

emoji_picker_flutter, flutter

More

Packages that depend on virtual_keypad