extra_field 0.1.0
extra_field: ^0.1.0 copied to clipboard
A rich composer input field for Flutter — inline trigger detection (#, @, $), action buttons, sticker panels, attachment previews, async validators, and a send button. Zero external dependencies.
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




