custom_tv_text_field 1.0.8 copy "custom_tv_text_field: ^1.0.8" to clipboard
custom_tv_text_field: ^1.0.8 copied to clipboard

A premium Flutter custom text field and on-screen keyboard for Android TV, Xiaomi TV, Philips TV, Tizen OS, and Fire TV Stick. Optimized for remote control D-pad navigation.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:custom_tv_text_field/custom_tv_text_field.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'TV Keyboard Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.deepPurple,
          brightness: Brightness.dark,
          surface: const Color(0xFF121212),
        ),
        scaffoldBackgroundColor: const Color(0xFF121212),
        useMaterial3: true,
      ),
      home: const TVLoginScreen(),
    );
  }
}

enum LoginSection { email, password, phone, bio, loginButton }

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

  @override
  State<TVLoginScreen> createState() => _TVLoginScreenState();
}

class _TVLoginScreenState extends State<TVLoginScreen> {
  final FocusNode screenFocusNode = FocusNode();
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
  final ValueNotifier<LoginSection> _currentSection =
      ValueNotifier<LoginSection>(LoginSection.email);
  final ValueNotifier<bool> _hasKeyboardOpen = ValueNotifier<bool>(false);

  final TextEditingController emailController = TextEditingController();
  final TextEditingController passwordController = TextEditingController();
  final TextEditingController phoneController = TextEditingController();
  final TextEditingController bioController = TextEditingController();

  final GlobalKey<CustomTVTextFieldState> emailKey =
      GlobalKey<CustomTVTextFieldState>();
  final GlobalKey<CustomTVTextFieldState> passwordKey =
      GlobalKey<CustomTVTextFieldState>();
  final GlobalKey<CustomTVTextFieldState> phoneKey =
      GlobalKey<CustomTVTextFieldState>();
  final GlobalKey<CustomTVTextFieldState> bioKey =
      GlobalKey<CustomTVTextFieldState>();

  @override
  void initState() {
    super.initState();
    screenFocusNode.requestFocus();
  }

  @override
  void dispose() {
    screenFocusNode.dispose();
    emailController.dispose();
    passwordController.dispose();
    phoneController.dispose();
    bioController.dispose();
    _currentSection.dispose();
    _hasKeyboardOpen.dispose();
    super.dispose();
  }

  bool _canHandleKeys() => screenFocusNode.hasFocus && !_hasKeyboardOpen.value;

  KeyEventResult _handleKeyEvent(KeyEvent event) {
    if (!_canHandleKeys() ||
        (event is! KeyDownEvent && event is! KeyRepeatEvent)) {
      return KeyEventResult.ignored;
    }

    final handlers = {
      LogicalKeyboardKey.arrowUp: () => _navigate(-1),
      LogicalKeyboardKey.arrowDown: () => _navigate(1),
      LogicalKeyboardKey.enter: _handleSelect,
      LogicalKeyboardKey.select: _handleSelect,
    };

    final handler = handlers[event.logicalKey];
    if (handler != null) {
      handler();
      return KeyEventResult.handled;
    }
    return KeyEventResult.ignored;
  }

  void _navigate(int delta) {
    final nextIndex =
        (LoginSection.values.indexOf(_currentSection.value) + delta);
    if (nextIndex >= 0 && nextIndex < LoginSection.values.length) {
      _currentSection.value = LoginSection.values[nextIndex];
    }
  }

  void _handleSelect() {
    switch (_currentSection.value) {
      case LoginSection.email:
        emailKey.currentState?.toggleKeyboard();
        break;
      case LoginSection.password:
        passwordKey.currentState?.toggleKeyboard();
        break;
      case LoginSection.phone:
        phoneKey.currentState?.toggleKeyboard();
        break;
      case LoginSection.bio:
        bioKey.currentState?.toggleKeyboard();
        break;
      case LoginSection.loginButton:
        _submitLogin();
        break;
    }
  }

  void _submitLogin() {
    if (_formKey.currentState?.validate() ?? false) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('Logging in as ${emailController.text}...'),
          backgroundColor: Colors.green,
          duration: const Duration(seconds: 2),
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Focus(
      focusNode: screenFocusNode,
      onKeyEvent: (_, event) => _handleKeyEvent(event),
      child: Scaffold(
        body: Center(
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: 400),
            child: Form(
              key: _formKey,
              child: ValueListenableBuilder<LoginSection>(
                valueListenable: _currentSection,
                builder: (context, section, _) => ValueListenableBuilder<bool>(
                  valueListenable: _hasKeyboardOpen,
                  builder: (context, hasKeyboardOpen, _) => SingleChildScrollView(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      crossAxisAlignment: CrossAxisAlignment.stretch,
                      children: [
                        const Text(
                          'TV Login',
                          style: TextStyle(
                            fontSize: 32,
                            fontWeight: FontWeight.bold,
                          ),
                          textAlign: TextAlign.center,
                        ),
                        const SizedBox(height: 16),
                        // Info banner about keyboard support
                        Container(
                          padding: const EdgeInsets.symmetric(
                            horizontal: 16,
                            vertical: 12,
                          ),
                          decoration: BoxDecoration(
                            color: Colors.blue.withValues(alpha: 0.1),
                            borderRadius: BorderRadius.circular(8),
                            border: Border.all(
                              color: Colors.blue.withValues(alpha: 0.3),
                            ),
                          ),
                          child: Row(
                            children: [
                              Icon(
                                Icons.keyboard,
                                color: Colors.blue[300],
                                size: 20,
                              ),
                              const SizedBox(width: 12),
                              Expanded(
                                child: Text(
                                  'Tip: Use your physical keyboard when the custom keyboard is open!',
                                  style: TextStyle(
                                    fontSize: 12,
                                    color: Colors.blue[200],
                                  ),
                                ),
                              ),
                            ],
                          ),
                        ),
                        const SizedBox(height: 32),
                        _Field(
                          fieldKey: emailKey,
                          controller: emailController,
                          label: "Email",
                          icon: Icons.email,
                          isSelected:
                              section == LoginSection.email && !hasKeyboardOpen,
                          isRequired: true,
                          textFieldType: TextFieldType.email,
                          onVisibilityChanged: (v) =>
                              _hasKeyboardOpen.value = v,
                        ),
                        const SizedBox(height: 16),
                        _Field(
                          fieldKey: passwordKey,
                          controller: passwordController,
                          label: "Password",
                          icon: Icons.lock,
                          isSelected:
                              section == LoginSection.password &&
                              !hasKeyboardOpen,
                          isRequired: true,
                          textFieldType: TextFieldType.password,
                          onVisibilityChanged: (v) =>
                              _hasKeyboardOpen.value = v,
                        ),
                        const SizedBox(height: 16),
                        _Field(
                          fieldKey: phoneKey,
                          controller: phoneController,
                          label: "Phone",
                          icon: Icons.phone,
                          isSelected:
                              section == LoginSection.phone && !hasKeyboardOpen,
                          isRequired: false,
                          textFieldType: TextFieldType.phone,
                          keyboardType: KeyboardType.numeric,
                          onVisibilityChanged: (v) =>
                              _hasKeyboardOpen.value = v,
                        ),
                        const SizedBox(height: 16),
                        _Field(
                          fieldKey: bioKey,
                          controller: bioController,
                          label: "Bio (Optional Multi-line)",
                          icon: Icons.person_outline,
                          isSelected:
                              section == LoginSection.bio && !hasKeyboardOpen,
                          isRequired: false,
                          maxLines: 3,
                          onVisibilityChanged: (v) =>
                              _hasKeyboardOpen.value = v,
                        ),
                        const SizedBox(height: 32),
                        _LoginButton(
                          isSelected:
                              section == LoginSection.loginButton &&
                              !hasKeyboardOpen,
                          onTap: _submitLogin,
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _Field extends StatelessWidget {
  final GlobalKey<CustomTVTextFieldState> fieldKey;
  final TextEditingController controller;
  final String label;
  final IconData icon;
  final bool isSelected;
  final ValueChanged<bool> onVisibilityChanged;
  final KeyboardType keyboardType;
  final bool isRequired;
  final TextFieldType textFieldType;
  final int maxLines;

  const _Field({
    required this.fieldKey,
    required this.controller,
    required this.label,
    required this.icon,
    required this.isSelected,
    required this.onVisibilityChanged,
    this.keyboardType = KeyboardType.alphabetic,
    this.isRequired = false,
    this.textFieldType = TextFieldType.other,
    this.maxLines = 1,
  });

  @override
  Widget build(BuildContext context) {
    return CustomTVTextField(
      key: fieldKey,
      controller: controller,
      hint: label,
      prefixIcon: Icon(icon, color: Colors.white70),
      isFocused: isSelected,
      onVisibilityChanged: onVisibilityChanged,
      onFieldSubmitted: (_) {}, // Removed unused onSubmitted parameter
      keyboardType: keyboardType,
      backgroundColor: Colors.grey[900],
      focusedBorderColor: Colors.white,
      borderRadius: 4,
      isRequired: isRequired, // Removed unused validator parameter
      textFieldType: textFieldType,
      maxLines: maxLines,
    );
  }
}

class _LoginButton extends StatelessWidget {
  final bool isSelected;
  final VoidCallback onTap;

  const _LoginButton({required this.isSelected, required this.onTap});

  @override
  Widget build(BuildContext context) {
    return AnimatedContainer(
      duration: const Duration(milliseconds: 200),
      height: 52,
      decoration: BoxDecoration(
        color: isSelected
            ? Colors.deepPurpleAccent
            : Colors.deepPurple.withValues(alpha: 0.3),
        borderRadius: BorderRadius.circular(4),
        border: Border.all(
          color: isSelected ? Colors.white : Colors.transparent,
          width: 2,
        ),
      ),
      child: InkWell(
        onTap: onTap,
        child: const Center(
          child: Text(
            "SIGN IN",
            style: TextStyle(
              color: Colors.white,
              fontSize: 16,
              fontWeight: FontWeight.bold,
              letterSpacing: 1.2,
            ),
          ),
        ),
      ),
    );
  }
}
8
likes
160
points
151
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A premium Flutter custom text field and on-screen keyboard for Android TV, Xiaomi TV, Philips TV, Tizen OS, and Fire TV Stick. Optimized for remote control D-pad navigation.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter

More

Packages that depend on custom_tv_text_field