ExtraField

A rich composer input field for Flutter — the kind of text input you see in WhatsApp, Telegram, Twitter/X, and Facebook. Inline trigger detection, action buttons, sticker panels, attachment previews, async validators, and a send button — all with zero external dependencies.

pub package License: MIT


Preview

Chat Composer Custom Builders
Chat Composer Custom Builders
Social Post Validators
Social Post Validators

Features

  • Inline Triggers — Type #, @, $, or any character to activate autocomplete suggestions with styled inline text. Supports both static lists and async search with debounce.
  • Action Buttons — Media picker, camera, sticker panel, location picker, or fully custom actions. All via callbacks — zero platform dependencies.
  • Sticker Picker — Tabbed grid panel with multiple packs, selection limits, and custom sticker builders.
  • Attachment Previews — Horizontal scrollable bar showing media thumbnails, location chips, and sticker previews with remove buttons.
  • Async Validators — Validate the composed value with parallel async validators. Supports auto-validate on change with debounce.
  • Send Button — Built-in or fully custom via sendButtonBuilder.
  • Full Customization — Every UI element is replaceable via builder callbacks, theme data, and user-provided strings.
  • RTL / Localization — Full RTL support. All strings configurable via ExtraFieldStrings.
  • Programmatic Control — External ExtraFieldController for reading/writing text, attachments, location, stickers, panels, and validation state.
  • SerializationtoMap() / fromMap() on all models and the composed value for easy persistence.

Getting Started

Installation

Add to your pubspec.yaml:

dependencies:
  extra_field: ^0.1.0

Then run:

flutter pub get

Import

import 'package:extra_field/extra_field.dart';

Usage

Minimal Example

ExtraFieldWidget(
  config: ExtraFieldConfig(
    hintText: 'Type a message...',
    onSend: (value) {
      print(value.text);
    },
  ),
)

Chat Composer (WhatsApp / Telegram style)

ExtraFieldWidget(
  config: ExtraFieldConfig(
    hintText: 'Type a message...',
    maxLines: 5,

    // Inline triggers
    triggers: [
      TriggerConfig(
        trigger: '#',
        suggestions: [
          TriggerMatch(trigger: '#', label: 'Flutter', id: '1'),
          TriggerMatch(trigger: '#', label: 'Dart', id: '2'),
        ],
        style: TextStyle(color: Colors.blue, fontWeight: FontWeight.w600),
      ),
      TriggerConfig(
        trigger: '@',
        onSearch: (query) async {
          // Your async search logic
          return await api.searchUsers(query);
        },
        debounce: Duration(milliseconds: 300),
        style: TextStyle(color: Colors.purple, fontWeight: FontWeight.w600),
      ),
    ],

    // Action buttons
    actions: [
      ExtraFieldAction.media(
        icon: Icons.attach_file,
        mediaTypes: [MediaType.image, MediaType.video],
        maxMediaItems: 5,
        onPickMedia: (type) async {
          // Your media picker logic — return list or null
          return await pickMedia(type);
        },
      ),
      ExtraFieldAction.stickers(
        icon: Icons.emoji_emotions,
        packs: [emojiPack, animalPack],
        maxStickers: 3,
      ),
      ExtraFieldAction.location(
        icon: Icons.location_on,
        onPickLocation: () async {
          // Your location picker logic — return LocationData or null
          return await pickLocation();
        },
      ),
      ExtraFieldAction.custom(
        id: 'quick_replies',
        icon: Icons.flash_on,
        panelBuilder: (context, onClose) {
          return MyQuickRepliesPanel(onClose: onClose);
        },
      ),
    ],

    // Send
    onSend: (value) {
      print('Text: ${value.text}');
      print('Triggers: ${value.triggers}');
      print('Attachments: ${value.attachments}');
      print('Location: ${value.location}');
      print('Stickers: ${value.stickers}');
    },
  ),
)

Social Post (Twitter/X style)

ExtraFieldWidget(
  initialValue: ExtraFieldValue(text: 'Hello '),
  config: ExtraFieldConfig(
    hintText: "What's happening?",
    maxLength: 280,
    maxLines: 8,
    minLines: 3,

    // Custom theme
    theme: ExtraFieldThemeData(
      primaryColor: Colors.blue,
      borderRadius: BorderRadius.circular(16),
      padding: EdgeInsets.all(16),
    ),

    // Custom send button
    sendButtonBuilder: (context, onSend) {
      return FilledButton(onPressed: onSend, child: Text('Post'));
    },

    triggers: [
      TriggerConfig(trigger: '#', suggestions: hashtags),
      TriggerConfig(trigger: '@', onSearch: searchUsers),
      TriggerConfig(
        trigger: '\$',
        suggestions: variables,
        style: TextStyle(color: Colors.green, fontStyle: FontStyle.italic),
      ),
    ],

    onSend: (value) => postToTimeline(value),
  ),
)

External Controller

final controller = ExtraFieldController();

// Pass to widget
ExtraFieldWidget(controller: controller, config: config)

// Programmatic control
controller.text = 'Hello #Flutter @john!';
controller.addAttachment(MediaItem(type: MediaType.image, name: 'photo.jpg'));
controller.setLocation(LocationData(latitude: 24.7, longitude: 46.6, name: 'Riyadh'));
controller.addSticker(Sticker(id: 's1', url: '', emoji: '\u{1F600}'));
controller.openPanel('stickers');

// Read composed value
final value = controller.value;       // ExtraFieldValue
final map = controller.toMap();        // Map<String, dynamic>

// Load a saved value
controller.loadValue(savedValue);

// Reset everything
controller.reset();

// Don't forget to dispose
controller.dispose();

Async Validators

ExtraFieldConfig(
  validators: [
    AsyncValidator(
      fieldId: 'text',
      validate: (value) async {
        if (value.text.trim().isEmpty) return 'Text is required';
        return null; // valid
      },
    ),
    AsyncValidator(
      fieldId: 'profanity',
      validate: (value) async {
        final result = await api.checkProfanity(value.text);
        return result.hasProfanity ? 'Contains blocked content' : null;
      },
      validateOnChange: true,          // auto-validate as user types
      debounce: Duration(milliseconds: 500),
    ),
    AsyncValidator(
      fieldId: 'media',
      validate: (value) async {
        if (value.attachments.isEmpty) return 'Attach at least 1 image';
        return null;
      },
    ),
  ],
)

// Manual validation
final result = await controller.validate();
if (result.isValid) {
  // send
} else {
  print(result.errors); // {'profanity': 'Contains blocked content'}
}

Custom Builders

Every UI element can be replaced:

ExtraFieldConfig(
  // Custom suggestion tiles
  triggers: [
    TriggerConfig(
      trigger: '@',
      suggestions: mentions,
      suggestionBuilder: (context, match, onSelect) {
        return ListTile(
          leading: CircleAvatar(backgroundImage: NetworkImage(match.imageUrl!)),
          title: Text('@${match.label}'),
          subtitle: Text(match.subtitle ?? ''),
          onTap: onSelect,
        );
      },
    ),
  ],

  // Custom action buttons
  actions: [
    ExtraFieldAction.media(
      icon: Icons.photo,
      onPickMedia: pickMedia,
      buttonBuilder: (context, onTap) {
        return ElevatedButton.icon(
          onPressed: onTap,
          icon: Icon(Icons.photo),
          label: Text('Photo'),
        );
      },
    ),
    ExtraFieldAction.stickers(
      icon: Icons.emoji_emotions,
      packs: packs,
      // Custom sticker item
      stickerBuilder: (context, sticker, isSelected, onTap) {
        return GestureDetector(
          onTap: onTap,
          child: AnimatedContainer(
            duration: Duration(milliseconds: 200),
            decoration: BoxDecoration(
              border: isSelected ? Border.all(color: Colors.blue, width: 2) : null,
              borderRadius: BorderRadius.circular(12),
            ),
            child: Center(child: Text(sticker.emoji ?? '', style: TextStyle(fontSize: 28))),
          ),
        );
      },
    ),
  ],

  // Custom send button
  sendButtonBuilder: (context, onSend) {
    return GradientButton(onPressed: onSend, child: Icon(Icons.send));
  },
)

Arabic / RTL

Directionality(
  textDirection: TextDirection.rtl,
  child: ExtraFieldWidget(
    config: ExtraFieldConfig(
      strings: ExtraFieldStrings(
        hintText: '\u0627\u0643\u062A\u0628 \u0631\u0633\u0627\u0644\u0629...',
        send: '\u0625\u0631\u0633\u0627\u0644',
        attachMedia: '\u0625\u0631\u0641\u0627\u0642 \u0648\u0633\u0627\u0626\u0637',
        image: '\u0635\u0648\u0631\u0629',
        video: '\u0641\u064A\u062F\u064A\u0648',
        audio: '\u0635\u0648\u062A',
        file: '\u0645\u0644\u0641',
        pickLocation: '\u0627\u062E\u062A\u064A\u0627\u0631 \u0627\u0644\u0645\u0648\u0642\u0639',
        stickers: '\u0645\u0644\u0635\u0642\u0627\u062A',
        remove: '\u062D\u0630\u0641',
        noSuggestions: '\u0644\u0627 \u062A\u0648\u062C\u062F \u0646\u062A\u0627\u0626\u062C',
        maxMediaReached: (max) => '\u0627\u0644\u062D\u062F \u0627\u0644\u0623\u0642\u0635\u0649 $max \u0639\u0646\u0627\u0635\u0631',
        maxStickersReached: (max) => '\u0627\u0644\u062D\u062F \u0627\u0644\u0623\u0642\u0635\u0649 $max \u0645\u0644\u0635\u0642\u0627\u062A',
      ),
      triggers: [...],
      actions: [...],
      onSend: (value) => send(value),
    ),
  ),
)

Architecture

ExtraFieldWidget
  \u251C\u2500 AttachmentPreview       \u2190 media thumbnails, location chips, sticker previews
  \u251C\u2500 TextField               \u2190 with TriggerTextEditingController (styled spans)
  \u251C\u2500 TriggerSuggestions      \u2190 overlay above text field
  \u251C\u2500 Action Bar              \u2190 ExtraFieldAction buttons + send button
  \u2514\u2500 Expandable Panel        \u2190 StickersPanel or custom panelBuilder

Key Classes

Class Description
ExtraFieldWidget Main composer widget. Drop into any layout.
ExtraFieldConfig All configuration: text, triggers, actions, send, validators, theme, strings.
ExtraFieldController State controller. Text, attachments, location, stickers, panels, validation.
ExtraFieldValue Immutable snapshot of composed data. Serializable via toMap()/fromMap().
TriggerConfig Defines a trigger character, search callback, styling, and suggestion UI.
TriggerMatch A detected or suggested trigger match (e.g. #Flutter, @john).
ExtraFieldAction Action button definition. Named constructors: .media(), .stickers(), .location(), .custom().
AsyncValidator Async validation rule with optional auto-validate on change and debounce.
ValidationResult Validation outcome with isValid, errors map, and errorFor(fieldId).
ExtraFieldThemeData Theme overrides for colors, padding, border radius, icon theme, and per-module themes.
ExtraFieldStrings All user-facing strings. Override for localization.

Models

Model Description
MediaItem Media attachment with type, name, path, url, size, duration, thumbnailUrl.
MediaType Enum: image, video, audio, file.
LocationData Location with latitude, longitude, name, address, placeId, url.
Sticker Sticker with id, url, emoji, name, packId, extra.
StickerPack Pack with id, name, icon, stickers, extra.

Theming

ExtraFieldConfig(
  theme: ExtraFieldThemeData(
    primaryColor: Colors.indigo,
    backgroundColor: Colors.white,
    surfaceColor: Colors.grey.shade100,
    borderRadius: BorderRadius.circular(12),
    padding: EdgeInsets.all(12),
    labelStyle: TextStyle(fontSize: 16),
    hintStyle: TextStyle(color: Colors.grey),
    iconTheme: IconThemeData(size: 22, color: Colors.indigo),
    stickersTheme: StickersThemeData(
      selectedBorderColor: Colors.indigo,
      tabIndicatorColor: Colors.indigo,
      spacing: 8,
    ),
  ),
)

Or provide it via InheritedWidget:

ExtraFieldTheme(
  data: myTheme,
  child: ExtraFieldWidget(config: config),
)

// Access anywhere:
final theme = ExtraFieldTheme.of(context);

Serialization

All models and the composed value support toMap() / fromMap():

// Save
final map = controller.toMap();
await storage.save('draft', jsonEncode(map));

// Restore
final saved = jsonDecode(await storage.load('draft'));
final value = ExtraFieldValue.fromMap(saved);
controller.loadValue(value);

Example App

The example directory contains a comprehensive demo app with 6 screens:

  1. Chat Composer — WhatsApp/Telegram style with triggers, media, stickers, location, and custom panels.
  2. Social Post — Twitter/X style with character limit, custom theme, $variable triggers, and custom send button.
  3. Arabic / RTL — Full RTL support with Arabic strings.
  4. Custom Builders — Custom suggestion tiles with avatars, pill-shaped action buttons, animated sticker selection, gradient send button.
  5. Validators — Async validation rules with auto-validate on change and manual trigger.
  6. Programmatic Control — External controller with buttons for every method and live state output.

Run it:

cd example
flutter run

Zero Dependencies

This package has no external dependencies beyond Flutter itself. Platform-specific functionality (image picker, camera, maps, etc.) is provided by your app through callbacks:

// You provide the implementation:
ExtraFieldAction.media(
  onPickMedia: (type) async {
    // Use image_picker, file_picker, or any package you prefer
    final file = await ImagePicker().pickImage(source: ImageSource.gallery);
    return file != null ? [MediaItem(type: type, path: file.path)] : null;
  },
)

ExtraFieldAction.location(
  onPickLocation: () async {
    // Use google_maps, geolocator, or any package you prefer
    final loc = await Navigator.push(context, MaterialPageRoute(builder: (_) => MapPicker()));
    return loc != null ? LocationData(latitude: loc.lat, longitude: loc.lng) : null;
  },
)

Additional Information

  • API Documentation: Full dartdoc on every public class, method, field, and parameter.
  • Issues & Feedback: GitHub Issues
  • License: MIT

Libraries

extra_field
A rich composer input field for Flutter — similar to WhatsApp, Telegram, Twitter/X, and Facebook chat/post input fields.