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.
Preview
| Chat Composer | Custom Builders |
|---|---|
![]() |
![]() |
| 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
ExtraFieldControllerfor reading/writing text, attachments, location, stickers, panels, and validation state. - Serialization —
toMap()/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:
- Chat Composer — WhatsApp/Telegram style with triggers, media, stickers, location, and custom panels.
- Social Post — Twitter/X style with character limit, custom theme,
$variabletriggers, and custom send button. - Arabic / RTL — Full RTL support with Arabic strings.
- Custom Builders — Custom suggestion tiles with avatars, pill-shaped action buttons, animated sticker selection, gradient send button.
- Validators — Async validation rules with auto-validate on change and manual trigger.
- 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.



