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.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:extra_field/extra_field.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Extra Field — Full Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const HomePage(),
);
}
}
// ═══════════════════════════════════════════════════════════════════
// HOME PAGE — Navigate to demos
// ═══════════════════════════════════════════════════════════════════
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final demos = <_DemoItem>[
_DemoItem(
title: 'Chat Composer',
subtitle: 'WhatsApp / Telegram style — triggers, media, stickers, location',
icon: Icons.chat_bubble_outline,
color: Colors.indigo,
page: const ChatDemo(),
),
_DemoItem(
title: 'Social Post',
subtitle: 'Twitter / X style — custom theme, \$ variables, custom builders',
icon: Icons.edit_note,
color: Colors.blue,
page: const PostDemo(),
),
_DemoItem(
title: 'Arabic / RTL',
subtitle: 'Full RTL support with Arabic strings',
icon: Icons.translate,
color: Colors.teal,
page: const ArabicDemo(),
),
_DemoItem(
title: 'Custom Builders',
subtitle: 'Custom send button, action buttons, suggestion tiles, sticker items',
icon: Icons.widgets_outlined,
color: Colors.deepPurple,
page: const CustomBuildersDemo(),
),
_DemoItem(
title: 'Validators',
subtitle: 'Async validation, auto-validate on change, manual validate',
icon: Icons.verified_outlined,
color: Colors.orange,
page: const ValidatorsDemo(),
),
_DemoItem(
title: 'Programmatic Control',
subtitle: 'External controller — loadValue, reset, read value, toMap',
icon: Icons.code,
color: Colors.green,
page: const ControllerDemo(),
),
];
return Scaffold(
appBar: AppBar(title: const Text('Extra Field Demos')),
body: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: demos.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final demo = demos[i];
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: CircleAvatar(
backgroundColor: demo.color.withAlpha(30),
child: Icon(demo.icon, color: demo.color),
),
title: Text(demo.title, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(demo.subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => demo.page),
),
),
);
},
),
);
}
}
class _DemoItem {
final String title;
final String subtitle;
final IconData icon;
final Color color;
final Widget page;
const _DemoItem({
required this.title,
required this.subtitle,
required this.icon,
required this.color,
required this.page,
});
}
// ═══════════════════════════════════════════════════════════════════
// SHARED SAMPLE DATA
// ═══════════════════════════════════════════════════════════════════
const _hashtagSuggestions = [
TriggerMatch(trigger: '#', label: 'Flutter', id: 'h1', subtitle: 'Mobile framework'),
TriggerMatch(trigger: '#', label: 'Dart', id: 'h2', subtitle: 'Programming language'),
TriggerMatch(trigger: '#', label: 'Firebase', id: 'h3', subtitle: 'Backend platform'),
TriggerMatch(trigger: '#', label: 'Mobile', id: 'h4', subtitle: 'Mobile development'),
TriggerMatch(trigger: '#', label: 'UI', id: 'h5', subtitle: 'User interface'),
TriggerMatch(trigger: '#', label: 'OpenSource', id: 'h6', subtitle: 'Open source software'),
TriggerMatch(trigger: '#', label: 'Android', id: 'h7', subtitle: 'Android platform'),
TriggerMatch(trigger: '#', label: 'iOS', id: 'h8', subtitle: 'Apple iOS'),
];
const _mentionSuggestions = [
TriggerMatch(trigger: '@', label: 'john', id: 'u1', subtitle: 'John Doe'),
TriggerMatch(trigger: '@', label: 'jane', id: 'u2', subtitle: 'Jane Smith'),
TriggerMatch(trigger: '@', label: 'mike', id: 'u3', subtitle: 'Mike Johnson'),
TriggerMatch(trigger: '@', label: 'sara', id: 'u4', subtitle: 'Sara Williams'),
TriggerMatch(trigger: '@', label: 'ali', id: 'u5', subtitle: 'Ali Ahmed'),
TriggerMatch(trigger: '@', label: 'fatima', id: 'u6', subtitle: 'Fatima Hassan'),
];
const _emojiPack = StickerPack(
id: 'emoji',
name: 'Emoji',
stickers: [
Sticker(id: 'e1', url: '', emoji: '\u{1F600}'),
Sticker(id: 'e2', url: '', emoji: '\u{1F389}'),
Sticker(id: 'e3', url: '', emoji: '\u{1F44D}'),
Sticker(id: 'e4', url: '', emoji: '\u{2764}'),
Sticker(id: 'e5', url: '', emoji: '\u{1F525}'),
Sticker(id: 'e6', url: '', emoji: '\u{1F680}'),
Sticker(id: 'e7', url: '', emoji: '\u{2B50}'),
Sticker(id: 'e8', url: '', emoji: '\u{1F308}'),
Sticker(id: 'e9', url: '', emoji: '\u{1F60D}'),
Sticker(id: 'e10', url: '', emoji: '\u{1F622}'),
Sticker(id: 'e11', url: '', emoji: '\u{1F914}'),
Sticker(id: 'e12', url: '', emoji: '\u{1F4AA}'),
],
);
const _animalPack = StickerPack(
id: 'animals',
name: 'Animals',
stickers: [
Sticker(id: 'a1', url: '', emoji: '\u{1F436}'),
Sticker(id: 'a2', url: '', emoji: '\u{1F431}'),
Sticker(id: 'a3', url: '', emoji: '\u{1F43B}'),
Sticker(id: 'a4', url: '', emoji: '\u{1F98A}'),
Sticker(id: 'a5', url: '', emoji: '\u{1F981}'),
Sticker(id: 'a6', url: '', emoji: '\u{1F427}'),
Sticker(id: 'a7', url: '', emoji: '\u{1F40D}'),
Sticker(id: 'a8', url: '', emoji: '\u{1F433}'),
],
);
const _foodPack = StickerPack(
id: 'food',
name: 'Food',
stickers: [
Sticker(id: 'f1', url: '', emoji: '\u{1F355}'),
Sticker(id: 'f2', url: '', emoji: '\u{1F354}'),
Sticker(id: 'f3', url: '', emoji: '\u{1F370}'),
Sticker(id: 'f4', url: '', emoji: '\u{2615}'),
Sticker(id: 'f5', url: '', emoji: '\u{1F36B}'),
Sticker(id: 'f6', url: '', emoji: '\u{1F34E}'),
],
);
Future<List<TriggerMatch>> _simulateMentionSearch(String query) async {
await Future.delayed(const Duration(milliseconds: 300));
return _mentionSuggestions
.where((m) => m.label.toLowerCase().contains(query.toLowerCase()))
.toList();
}
Future<List<MediaItem>?> _simulateMediaPick(MediaType type) async {
await Future.delayed(const Duration(milliseconds: 200));
final ext = switch (type) {
MediaType.image => 'jpg',
MediaType.video => 'mp4',
MediaType.audio => 'mp3',
MediaType.file => 'pdf',
};
return [
MediaItem(
type: type,
name: '${type.name}_${DateTime.now().millisecondsSinceEpoch}.$ext',
),
];
}
Future<LocationData?> _simulateLocationPick() async {
await Future.delayed(const Duration(milliseconds: 200));
return const LocationData(
latitude: 24.7136,
longitude: 46.6753,
name: 'Riyadh',
address: 'Riyadh, Saudi Arabia',
placeId: 'ChIJzUo0LE4JJz4Rk-Xj5KEpvTQ',
);
}
// ═══════════════════════════════════════════════════════════════════
// DEMO 1 — CHAT COMPOSER (WhatsApp / Telegram)
// ═══════════════════════════════════════════════════════════════════
//
// Showcases:
// - ExtraFieldConfig: hintText, maxLines, minLines, textInputAction
// - TriggerConfig: # (static suggestions), @ (async onSearch + debounce)
// - TriggerMatch: trigger, label, id, subtitle
// - ExtraFieldAction.media: mediaTypes, maxMediaItems, onPickMedia
// - ExtraFieldAction.stickers: packs, maxStickers, crossAxisCount
// - ExtraFieldAction.location: onPickLocation
// - ExtraFieldAction.custom: onTap, panelBuilder
// - ExtraFieldConfig.onSend + showSendButton
// - ExtraFieldWidget: controller, onChanged, initialValue
// - ExtraFieldStrings: customized strings
// - ExtraFieldController: reset
class ChatDemo extends StatefulWidget {
const ChatDemo({super.key});
@override
State<ChatDemo> createState() => _ChatDemoState();
}
class _ChatDemoState extends State<ChatDemo> {
final _controller = ExtraFieldController();
final List<Map<String, dynamic>> _messages = [];
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Chat Composer'),
actions: [
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Clear messages',
onPressed: () => setState(() => _messages.clear()),
),
],
),
body: Column(
children: [
// Message list
Expanded(
child: _messages.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline,
size: 64, color: Colors.grey.shade300),
const SizedBox(height: 16),
Text(
'Type a message below.\n'
'Use # for hashtags, @ for mentions.\n'
'Try attaching media, location, or stickers.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
],
),
),
)
: ListView.builder(
reverse: true,
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
itemBuilder: (context, index) {
final msg = _messages[_messages.length - 1 - index];
return _MessageBubble(data: msg);
},
),
),
// Composer bar
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(20),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: ExtraFieldWidget(
controller: _controller,
config: ExtraFieldConfig(
// ── Text field options ──
hintText: 'Type a message...',
maxLines: 5,
minLines: 1,
textInputAction: TextInputAction.newline,
// ── Triggers ──
triggers: [
// #hashtag — static suggestions list
TriggerConfig(
trigger: '#',
suggestions: _hashtagSuggestions,
style: const TextStyle(
color: Colors.blue,
fontWeight: FontWeight.w600,
),
suggestionsMaxHeight: 200,
),
// @mention — async search with debounce
TriggerConfig(
trigger: '@',
onSearch: _simulateMentionSearch,
debounce: const Duration(milliseconds: 250),
style: const TextStyle(
color: Colors.purple,
fontWeight: FontWeight.w600,
),
suggestionsMaxHeight: 180,
),
],
// ── Action buttons ──
actions: [
// Media: image + video + audio + file
ExtraFieldAction.media(
icon: Icons.attach_file,
tooltip: 'Attach',
mediaTypes: const [
MediaType.image,
MediaType.video,
MediaType.audio,
MediaType.file,
],
maxMediaItems: 5,
onPickMedia: _simulateMediaPick,
),
// Camera shortcut (single media type = no bottom sheet)
ExtraFieldAction.media(
id: 'camera',
icon: Icons.camera_alt_outlined,
tooltip: 'Camera',
mediaTypes: const [MediaType.image],
onPickMedia: (type) async {
return [
MediaItem(
type: MediaType.image,
name: 'camera_${DateTime.now().millisecondsSinceEpoch}.jpg',
),
];
},
),
// Stickers panel
const ExtraFieldAction.stickers(
icon: Icons.emoji_emotions_outlined,
tooltip: 'Stickers',
packs: [_emojiPack, _animalPack, _foodPack],
maxStickers: 3,
crossAxisCount: 4,
stickerSize: 60,
),
// Location picker
ExtraFieldAction.location(
icon: Icons.location_on_outlined,
tooltip: 'Location',
onPickLocation: _simulateLocationPick,
),
// Custom action: quick reply panel
ExtraFieldAction.custom(
id: 'quick_replies',
icon: Icons.flash_on_outlined,
tooltip: 'Quick replies',
panelBuilder: (context, onClose) {
return SizedBox(
height: 120,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.all(12),
children: [
'Thanks!',
'On my way',
'See you soon',
'Got it!',
'Sounds good',
].map((text) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
label: Text(text),
onPressed: () {
_controller.text = text;
onClose();
},
),
);
}).toList(),
),
);
},
),
],
// ── Send ──
showSendButton: true,
onSend: (value) {
if (value.isEmpty) return;
setState(() => _messages.add(value.toMap()));
_controller.reset();
},
// ── Strings ──
strings: const ExtraFieldStrings(
hintText: 'Type a message...',
send: 'Send',
attachMedia: 'Attach media',
image: 'Image',
video: 'Video',
audio: 'Audio',
file: 'File',
pickLocation: 'Pick location',
stickers: 'Stickers',
remove: 'Remove',
noSuggestions: 'No results',
),
),
onChanged: (value) {
debugPrint(
'Changed: "${value.text}" '
'| triggers: ${value.triggers.map((t) => t.display).join(", ")} '
'| attachments: ${value.attachments.length} '
'| location: ${value.location != null} '
'| stickers: ${value.stickers.length}',
);
},
),
),
),
],
),
);
}
}
class _MessageBubble extends StatelessWidget {
final Map<String, dynamic> data;
const _MessageBubble({required this.data});
@override
Widget build(BuildContext context) {
final text = data['text'] as String? ?? '';
final triggers = data['triggers'] as List? ?? [];
final attachments = data['attachments'] as List? ?? [];
final location = data['location'] as Map<String, dynamic>?;
final stickers = data['stickers'] as List? ?? [];
return Align(
alignment: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(bottom: 8, left: 60),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(4),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (text.isNotEmpty)
Text(text, style: const TextStyle(color: Colors.white)),
if (triggers.isNotEmpty) ...[
const SizedBox(height: 4),
Wrap(
spacing: 4,
children: triggers.map((t) {
final m = t as Map<String, dynamic>;
return Text(
'${m['trigger']}${m['label']}',
style: TextStyle(
color: Colors.white.withAlpha(200),
fontSize: 12,
fontWeight: FontWeight.w600,
),
);
}).toList(),
),
],
if (attachments.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
runSpacing: 4,
children: attachments.map((a) {
final m = a as Map<String, dynamic>;
final type = m['type'] as String;
final icon = switch (type) {
'image' => Icons.image,
'video' => Icons.videocam,
'audio' => Icons.audiotrack,
_ => Icons.insert_drive_file,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withAlpha(30),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: Colors.white70),
const SizedBox(width: 4),
Text(
m['name'] as String? ?? type,
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
),
);
}).toList(),
),
],
if (location != null) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withAlpha(30),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.location_on, size: 14, color: Colors.white70),
const SizedBox(width: 4),
Text(
location['address'] as String? ??
location['name'] as String? ??
'Location',
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
),
),
],
if (stickers.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
children: stickers.map((s) {
final m = s as Map<String, dynamic>;
return Text(m['emoji'] as String? ?? '',
style: const TextStyle(fontSize: 24));
}).toList(),
),
],
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════
// DEMO 2 — SOCIAL POST (Twitter / X)
// ═══════════════════════════════════════════════════════════════════
//
// Showcases:
// - ExtraFieldConfig: maxLength (280 chars), keyboardType
// - TriggerConfig: $ variable trigger (3rd trigger type)
// - TriggerMatch: imageUrl (avatar in suggestions)
// - ExtraFieldConfig.theme: custom ExtraFieldThemeData
// - ExtraFieldThemeData: primaryColor, backgroundColor, borderRadius, labelStyle, hintStyle, padding, iconTheme
// - StickersThemeData: selectedBorderColor, tabIndicatorColor, spacing
// - ExtraFieldConfig.sendButtonBuilder: custom send button widget
// - ExtraFieldWidget.initialValue: pre-fill the field
class PostDemo extends StatefulWidget {
const PostDemo({super.key});
@override
State<PostDemo> createState() => _PostDemoState();
}
class _PostDemoState extends State<PostDemo> {
final _controller = ExtraFieldController();
int _charCount = 0;
static const _variableSuggestions = [
TriggerMatch(trigger: '\$', label: 'username', id: 'v1', subtitle: 'Current user'),
TriggerMatch(trigger: '\$', label: 'date', id: 'v2', subtitle: 'Today\'s date'),
TriggerMatch(trigger: '\$', label: 'time', id: 'v3', subtitle: 'Current time'),
TriggerMatch(trigger: '\$', label: 'price', id: 'v4', subtitle: 'Product price'),
TriggerMatch(trigger: '\$', label: 'total', id: 'v5', subtitle: 'Order total'),
];
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Social Post')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Character counter
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'$_charCount / 280',
style: TextStyle(
color: _charCount > 280 ? Colors.red : Colors.grey,
fontWeight: _charCount > 280 ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
// Composer
ExtraFieldWidget(
controller: _controller,
initialValue: const ExtraFieldValue(text: 'Hello '),
config: ExtraFieldConfig(
hintText: "What's happening?",
maxLines: 8,
minLines: 3,
maxLength: 280,
keyboardType: TextInputType.multiline,
// ── Custom theme ──
theme: ExtraFieldThemeData(
primaryColor: Colors.blue.shade400,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest,
surfaceColor: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
padding: const EdgeInsets.all(16),
labelStyle: const TextStyle(fontSize: 16, height: 1.4),
hintStyle: TextStyle(
fontSize: 16,
color: Colors.grey.shade400,
),
iconTheme: IconThemeData(
size: 20,
color: Colors.blue.shade400,
),
stickersTheme: StickersThemeData(
selectedBorderColor: Colors.blue.shade400,
tabIndicatorColor: Colors.blue.shade400,
spacing: 10,
),
),
// ── Three trigger types ──
triggers: [
TriggerConfig(
trigger: '#',
suggestions: _hashtagSuggestions,
style: TextStyle(color: Colors.blue.shade400),
),
TriggerConfig(
trigger: '@',
onSearch: (query) async {
await Future.delayed(const Duration(milliseconds: 200));
return _mentionSuggestions
.where((m) =>
m.label.toLowerCase().contains(query.toLowerCase()))
.toList();
},
style: TextStyle(color: Colors.blue.shade400),
),
// $ for variables
TriggerConfig(
trigger: '\$',
suggestions: _variableSuggestions,
style: TextStyle(
color: Colors.green.shade600,
fontWeight: FontWeight.w600,
fontStyle: FontStyle.italic,
),
debounce: const Duration(milliseconds: 100),
),
],
// ── Actions ──
actions: [
ExtraFieldAction.media(
icon: Icons.image_outlined,
tooltip: 'Photo',
mediaTypes: const [MediaType.image],
maxMediaItems: 4,
onPickMedia: _simulateMediaPick,
),
const ExtraFieldAction.stickers(
icon: Icons.emoji_emotions_outlined,
tooltip: 'Emoji',
packs: [_emojiPack],
maxStickers: 5,
crossAxisCount: 6,
stickerSize: 48,
),
ExtraFieldAction.location(
icon: Icons.location_on_outlined,
tooltip: 'Location',
onPickLocation: _simulateLocationPick,
),
],
// ── Custom send button ──
showSendButton: true,
sendButtonBuilder: (context, onSend) {
return FilledButton(
onPressed: onSend,
style: FilledButton.styleFrom(
backgroundColor: Colors.blue.shade400,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: const Text('Post'),
);
},
onSend: (value) {
if (value.isEmpty) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Posted: "${value.text}" '
'(${value.triggers.length} triggers, '
'${value.attachments.length} media, '
'${value.stickers.length} stickers)',
),
),
);
_controller.reset();
setState(() => _charCount = 0);
},
strings: const ExtraFieldStrings(
hintText: "What's happening?",
send: 'Post',
noSuggestions: 'No matches',
),
),
onChanged: (value) {
setState(() => _charCount = value.text.length);
},
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 12),
Text(
'Features shown:\n'
' - maxLength: 280 chars with counter\n'
' - # hashtag (static), @ mention (async), \$ variable (styled italic)\n'
' - Custom ExtraFieldThemeData (colors, radius, padding, icon theme)\n'
' - StickersThemeData (indicator color, spacing)\n'
' - sendButtonBuilder: FilledButton "Post"\n'
' - initialValue: pre-filled text\n'
' - maxMediaItems: 4',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════
// DEMO 3 — ARABIC / RTL
// ═══════════════════════════════════════════════════════════════════
//
// Showcases:
// - ExtraFieldStrings: full Arabic localization
// - RTL text direction
// - Arabic trigger labels
// - maxMediaReached / maxStickersReached function strings
class ArabicDemo extends StatefulWidget {
const ArabicDemo({super.key});
@override
State<ArabicDemo> createState() => _ArabicDemoState();
}
class _ArabicDemoState extends State<ArabicDemo> {
final _controller = ExtraFieldController();
ExtraFieldValue? _lastSent;
static const _arabicHashtags = [
TriggerMatch(trigger: '#', label: '\u0641\u0644\u0627\u062A\u0631', id: 'ah1', subtitle: '\u0625\u0637\u0627\u0631 \u0639\u0645\u0644 \u0644\u0644\u062A\u0637\u0628\u064A\u0642\u0627\u062A'),
TriggerMatch(trigger: '#', label: '\u062F\u0627\u0631\u062A', id: 'ah2', subtitle: '\u0644\u063A\u0629 \u0628\u0631\u0645\u062C\u0629'),
TriggerMatch(trigger: '#', label: '\u062A\u0637\u0648\u064A\u0631', id: 'ah3', subtitle: '\u062A\u0637\u0648\u064A\u0631 \u0627\u0644\u0628\u0631\u0645\u062C\u064A\u0627\u062A'),
TriggerMatch(trigger: '#', label: '\u0627\u0644\u0631\u064A\u0627\u0636', id: 'ah4', subtitle: '\u0627\u0644\u0645\u0645\u0644\u0643\u0629 \u0627\u0644\u0639\u0631\u0628\u064A\u0629 \u0627\u0644\u0633\u0639\u0648\u062F\u064A\u0629'),
];
static const _arabicMentions = [
TriggerMatch(trigger: '@', label: '\u0623\u062D\u0645\u062F', id: 'am1', subtitle: '\u0623\u062D\u0645\u062F \u0645\u062D\u0645\u062F'),
TriggerMatch(trigger: '@', label: '\u0641\u0627\u0637\u0645\u0629', id: 'am2', subtitle: '\u0641\u0627\u0637\u0645\u0629 \u062D\u0633\u0646'),
TriggerMatch(trigger: '@', label: '\u0639\u0644\u064A', id: 'am3', subtitle: '\u0639\u0644\u064A \u0639\u0628\u062F\u0627\u0644\u0644\u0647'),
];
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
appBar: AppBar(title: const Text('\u062D\u0642\u0644 \u0625\u0636\u0627\u0641\u064A \u2014 \u0639\u0631\u0628\u064A')),
body: Column(
children: [
// Show last sent value
Expanded(
child: _lastSent == null
? Center(
child: Text(
'\u0627\u0643\u062A\u0628 \u0631\u0633\u0627\u0644\u0629 \u0628\u0627\u0644\u0623\u0633\u0641\u0644\n\u0627\u0633\u062A\u062E\u062F\u0645 # \u0644\u0644\u0647\u0627\u0634\u062A\u0627\u063A \u0648 @ \u0644\u0644\u0625\u0634\u0627\u0631\u0629',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
)
: Padding(
padding: const EdgeInsets.all(16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('\u0622\u062E\u0631 \u0631\u0633\u0627\u0644\u0629:',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(_lastSent!.text),
if (_lastSent!.triggers.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: _lastSent!.triggers
.map((t) => Chip(label: Text(t.display)))
.toList(),
),
],
],
),
),
),
),
),
// Composer
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(20),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: ExtraFieldWidget(
controller: _controller,
config: ExtraFieldConfig(
maxLines: 4,
triggers: [
TriggerConfig(
trigger: '#',
suggestions: _arabicHashtags,
style: const TextStyle(
color: Colors.teal,
fontWeight: FontWeight.w600,
),
),
TriggerConfig(
trigger: '@',
suggestions: _arabicMentions,
style: const TextStyle(
color: Colors.purple,
fontWeight: FontWeight.w600,
),
),
],
actions: [
ExtraFieldAction.media(
icon: Icons.attach_file,
tooltip: '\u0625\u0631\u0641\u0627\u0642',
mediaTypes: const [MediaType.image, MediaType.video],
onPickMedia: _simulateMediaPick,
),
ExtraFieldAction.location(
icon: Icons.location_on_outlined,
tooltip: '\u0627\u0644\u0645\u0648\u0642\u0639',
onPickLocation: () async {
return const LocationData(
latitude: 24.7136,
longitude: 46.6753,
name: '\u0627\u0644\u0631\u064A\u0627\u0636',
address: '\u0627\u0644\u0631\u064A\u0627\u0636\u060C \u0627\u0644\u0645\u0645\u0644\u0643\u0629 \u0627\u0644\u0639\u0631\u0628\u064A\u0629 \u0627\u0644\u0633\u0639\u0648\u062F\u064A\u0629',
);
},
),
const ExtraFieldAction.stickers(
icon: Icons.emoji_emotions_outlined,
tooltip: '\u0645\u0644\u0635\u0642\u0627\u062A',
packs: [_emojiPack],
maxStickers: 2,
),
],
onSend: (value) {
if (value.isEmpty) return;
setState(() => _lastSent = value);
_controller.reset();
},
// ── Full Arabic strings ──
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',
validating: '\u062C\u0627\u0631\u064A \u0627\u0644\u062A\u062D\u0642\u0642...',
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',
),
),
),
),
),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════
// DEMO 4 — CUSTOM BUILDERS
// ═══════════════════════════════════════════════════════════════════
//
// Showcases:
// - TriggerConfig.suggestionBuilder: custom suggestion tile
// - ExtraFieldAction.buttonBuilder: custom action button widgets
// - ExtraFieldAction.stickerBuilder: custom sticker item widget
// - ExtraFieldConfig.sendButtonBuilder: custom send button
// - TriggerMatch.imageUrl: avatar images in suggestions
class CustomBuildersDemo extends StatefulWidget {
const CustomBuildersDemo({super.key});
@override
State<CustomBuildersDemo> createState() => _CustomBuildersDemoState();
}
class _CustomBuildersDemoState extends State<CustomBuildersDemo> {
final _controller = ExtraFieldController();
static const _mentionsWithImages = [
TriggerMatch(
trigger: '@',
label: 'john',
id: 'u1',
subtitle: 'John Doe - Designer',
imageUrl: 'https://i.pravatar.cc/150?u=john',
),
TriggerMatch(
trigger: '@',
label: 'jane',
id: 'u2',
subtitle: 'Jane Smith - Developer',
imageUrl: 'https://i.pravatar.cc/150?u=jane',
),
TriggerMatch(
trigger: '@',
label: 'mike',
id: 'u3',
subtitle: 'Mike Johnson - PM',
imageUrl: 'https://i.pravatar.cc/150?u=mike',
),
];
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Custom Builders')),
body: Column(
children: [
const Expanded(
child: Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text(
'Every widget is customizable via builders.\n\n'
'Try @ for custom suggestion tiles with avatars.\n'
'Notice the custom action buttons and send button.',
textAlign: TextAlign.center,
),
),
),
),
Container(
decoration: BoxDecoration(
color: colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(20),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: ExtraFieldWidget(
controller: _controller,
config: ExtraFieldConfig(
maxLines: 4,
triggers: [
// @ mentions with custom suggestionBuilder
TriggerConfig(
trigger: '@',
suggestions: _mentionsWithImages,
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
),
// ── Custom suggestion builder ──
suggestionBuilder: (context, match, onSelect) {
return InkWell(
onTap: onSelect,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundImage: match.imageUrl != null
? NetworkImage(match.imageUrl!)
: null,
child: match.imageUrl == null
? Text(match.label[0].toUpperCase())
: null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'@${match.label}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
if (match.subtitle != null)
Text(
match.subtitle!,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
Icon(Icons.arrow_forward_ios,
size: 14, color: Colors.grey.shade400),
],
),
),
);
},
),
TriggerConfig(
trigger: '#',
suggestions: _hashtagSuggestions,
style: TextStyle(color: Colors.orange.shade700),
),
],
actions: [
// Media with custom buttonBuilder
ExtraFieldAction.media(
icon: Icons.add_photo_alternate,
mediaTypes: const [MediaType.image],
onPickMedia: _simulateMediaPick,
// ── Custom button builder ──
buttonBuilder: (context, onTap) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Material(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_photo_alternate,
size: 18,
color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text(
'Photo',
style: TextStyle(
fontSize: 12,
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
);
},
),
// Stickers with custom stickerBuilder
ExtraFieldAction.stickers(
icon: Icons.emoji_emotions_outlined,
packs: const [_emojiPack, _animalPack],
maxStickers: 3,
crossAxisCount: 4,
// ── Custom sticker builder ──
stickerBuilder: (context, sticker, isSelected, onTap) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
border: isSelected
? Border.all(color: colorScheme.primary, width: 2)
: null,
boxShadow: isSelected
? [
BoxShadow(
color: colorScheme.primary.withAlpha(60),
blurRadius: 8,
spreadRadius: 1,
),
]
: null,
),
child: Center(
child: Text(
sticker.emoji ?? '',
style: TextStyle(
fontSize: isSelected ? 32 : 28),
),
),
),
);
},
// Custom button builder for stickers too
buttonBuilder: (context, onTap) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Material(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.emoji_emotions_outlined,
size: 18,
color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text(
'Emoji',
style: TextStyle(
fontSize: 12,
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
);
},
),
],
// ── Custom send button ──
showSendButton: true,
sendButtonBuilder: (context, onSend) {
return Container(
margin: const EdgeInsets.only(left: 8),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [colorScheme.primary, colorScheme.tertiary],
),
borderRadius: BorderRadius.circular(24),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(24),
onTap: onSend,
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
child: Icon(Icons.send, color: Colors.white, size: 20),
),
),
),
);
},
onSend: (value) {
if (value.isEmpty) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sent: ${value.text}')),
);
_controller.reset();
},
),
),
),
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════
// DEMO 5 — VALIDATORS
// ═══════════════════════════════════════════════════════════════════
//
// Showcases:
// - AsyncValidator: fieldId, validate function, debounce, validateOnChange
// - ValidationResult: isValid, errors, errorFor, hasErrorFor
// - ExtraFieldController.validate(): manual validation trigger
// - ExtraFieldController.errors: read current errors
// - ExtraFieldController.isValidating: loading state
class ValidatorsDemo extends StatefulWidget {
const ValidatorsDemo({super.key});
@override
State<ValidatorsDemo> createState() => _ValidatorsDemoState();
}
class _ValidatorsDemoState extends State<ValidatorsDemo> {
final _controller = ExtraFieldController();
ValidationResult? _lastResult;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _validateAndShow() async {
final result = await _controller.validate();
setState(() => _lastResult = result);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.isValid ? 'Valid!' : 'Errors: ${result.errors}'),
backgroundColor: result.isValid ? Colors.green : Colors.red,
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Validators')),
body: Column(
children: [
// Validation status
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Validation Rules:',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
_ruleCard('text', 'Text must not be empty'),
_ruleCard('text_length', 'Text must be at least 5 characters'),
_ruleCard('profanity',
'Text must not contain "spam" (auto-validates on change)'),
_ruleCard('media', 'Must have at least 1 attachment'),
const SizedBox(height: 16),
Row(
children: [
FilledButton.icon(
onPressed: _validateAndShow,
icon: const Icon(Icons.check_circle_outline),
label: const Text('Validate manually'),
),
const SizedBox(width: 12),
if (_controller.isValidating)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
if (_lastResult != null) ...[
const SizedBox(height: 16),
Card(
color: _lastResult!.isValid
? Colors.green.shade50
: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_lastResult!.isValid
? Icons.check_circle
: Icons.error,
color: _lastResult!.isValid
? Colors.green
: Colors.red,
),
const SizedBox(width: 8),
Text(
_lastResult!.isValid ? 'All valid' : 'Has errors',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
if (_lastResult!.errors.isNotEmpty) ...[
const SizedBox(height: 8),
...(_lastResult!.errors.entries.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'${e.key}: ${e.value}',
style: TextStyle(
color: Colors.red.shade700, fontSize: 13),
),
))),
],
],
),
),
),
],
],
),
),
),
// Composer with validators
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(20),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: ExtraFieldWidget(
controller: _controller,
config: ExtraFieldConfig(
hintText: 'Type something (min 5 chars, no "spam")...',
maxLines: 3,
triggers: [
TriggerConfig(
trigger: '#',
suggestions: _hashtagSuggestions,
style: const TextStyle(color: Colors.blue),
),
],
actions: [
ExtraFieldAction.media(
icon: Icons.attach_file,
tooltip: 'Attach (required by validator)',
mediaTypes: const [MediaType.image],
onPickMedia: _simulateMediaPick,
),
],
// ── Validators ──
validators: [
// Required text
AsyncValidator(
fieldId: 'text',
validate: (value) async {
if (value.text.trim().isEmpty) {
return 'Text is required';
}
return null;
},
),
// Min length
AsyncValidator(
fieldId: 'text_length',
validate: (value) async {
if (value.text.isNotEmpty && value.text.length < 5) {
return 'Must be at least 5 characters';
}
return null;
},
),
// Profanity check — auto-validates on change
AsyncValidator(
fieldId: 'profanity',
validate: (value) async {
// Simulate async check
await Future.delayed(const Duration(milliseconds: 300));
if (value.text.toLowerCase().contains('spam')) {
return 'Text contains blocked content';
}
return null;
},
validateOnChange: true,
debounce: const Duration(milliseconds: 500),
),
// Media required
AsyncValidator(
fieldId: 'media',
validate: (value) async {
if (value.attachments.isEmpty) {
return 'At least 1 attachment is required';
}
return null;
},
),
],
onSend: (value) {
if (value.isEmpty) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Sent successfully!'),
backgroundColor: Colors.green,
),
);
_controller.reset();
setState(() => _lastResult = null);
},
),
onChanged: (_) {
// Refresh UI to show auto-validation errors
setState(() {});
},
),
),
),
],
),
);
}
Widget _ruleCard(String fieldId, String description) {
final error = _controller.errorFor(fieldId);
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Icon(
error != null ? Icons.close : Icons.circle_outlined,
size: 16,
color: error != null ? Colors.red : Colors.grey,
),
const SizedBox(width: 8),
Expanded(
child: Text(
description,
style: TextStyle(
fontSize: 13,
color: error != null ? Colors.red : null,
),
),
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════
// DEMO 6 — PROGRAMMATIC CONTROLLER
// ═══════════════════════════════════════════════════════════════════
//
// Showcases:
// - ExtraFieldController: external instance
// - controller.text: read/write text
// - controller.addAttachment / removeAttachment / clearAttachments
// - controller.setLocation / clearLocation
// - controller.addSticker / removeSticker / toggleSticker / clearStickers
// - controller.openPanel / closePanel / togglePanel
// - controller.value: read the full ExtraFieldValue
// - controller.toMap(): serialize
// - controller.loadValue(): restore from ExtraFieldValue
// - controller.reset(): clear everything
// - controller.attachmentCount, stickerCount, hasLocation, isPanelOpen
class ControllerDemo extends StatefulWidget {
const ControllerDemo({super.key});
@override
State<ControllerDemo> createState() => _ControllerDemoState();
}
class _ControllerDemoState extends State<ControllerDemo> {
final _controller = ExtraFieldController();
String _output = '';
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _showState() {
final v = _controller.value;
setState(() {
_output = 'text: "${v.text}"\n'
'triggers: ${v.triggers.map((t) => t.display).toList()}\n'
'attachments: ${v.attachments.length}\n'
'location: ${v.location?.address ?? "none"}\n'
'stickers: ${v.stickers.map((s) => s.emoji ?? s.id).toList()}\n'
'panel: ${_controller.activePanelId ?? "none"}\n'
'isPanelOpen: ${_controller.isPanelOpen}\n'
'---\n'
'toMap():\n${_prettyMap(_controller.toMap())}';
});
}
String _prettyMap(Map<String, dynamic> map) {
final buffer = StringBuffer();
for (final entry in map.entries) {
buffer.writeln(' ${entry.key}: ${entry.value}');
}
return buffer.toString();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Programmatic Control')),
body: Column(
children: [
// Control buttons + output
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Controller Methods:',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_actionButton('Set text', () {
_controller.text = 'Hello #Flutter @john!';
_showState();
}),
_actionButton('Add image', () {
_controller.addAttachment(
MediaItem(
type: MediaType.image,
name: 'photo_${DateTime.now().millisecondsSinceEpoch}.jpg',
),
);
_showState();
}),
_actionButton('Add video', () {
_controller.addAttachment(
MediaItem(
type: MediaType.video,
name: 'video_${DateTime.now().millisecondsSinceEpoch}.mp4',
),
);
_showState();
}),
_actionButton('Clear attachments', () {
_controller.clearAttachments();
_showState();
}),
_actionButton('Set location', () {
_controller.setLocation(const LocationData(
latitude: 24.7136,
longitude: 46.6753,
name: 'Riyadh',
address: 'Riyadh, Saudi Arabia',
));
_showState();
}),
_actionButton('Clear location', () {
_controller.clearLocation();
_showState();
}),
_actionButton('Toggle sticker', () {
_controller.toggleSticker(
const Sticker(id: 'e1', url: '', emoji: '\u{1F600}'));
_showState();
}),
_actionButton('Open panel', () {
_controller.openPanel('stickers');
_showState();
}),
_actionButton('Close panel', () {
_controller.closePanel();
_showState();
}),
_actionButton('Read value', _showState),
_actionButton('Load value', () {
_controller.loadValue(const ExtraFieldValue(
text: 'Loaded from value!',
attachments: [
MediaItem(type: MediaType.image, name: 'loaded.jpg'),
],
location: LocationData(
latitude: 21.4225,
longitude: 39.8262,
name: 'Mecca',
address: 'Mecca, Saudi Arabia',
),
stickers: [
Sticker(id: 'e4', url: '', emoji: '\u{2764}'),
],
));
_showState();
}),
_actionButton('Reset', () {
_controller.reset();
_showState();
}),
],
),
if (_output.isNotEmpty) ...[
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: SelectableText(
_output,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
],
],
),
),
),
// Composer
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(20),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: ExtraFieldWidget(
controller: _controller,
config: ExtraFieldConfig(
hintText: 'Use buttons above to control...',
maxLines: 3,
triggers: [
TriggerConfig(
trigger: '#',
suggestions: _hashtagSuggestions,
style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.w600),
),
TriggerConfig(
trigger: '@',
suggestions: _mentionSuggestions,
style: const TextStyle(color: Colors.purple, fontWeight: FontWeight.w600),
),
],
actions: [
ExtraFieldAction.media(
icon: Icons.attach_file,
mediaTypes: const [MediaType.image, MediaType.video],
onPickMedia: _simulateMediaPick,
),
const ExtraFieldAction.stickers(
icon: Icons.emoji_emotions_outlined,
packs: [_emojiPack, _animalPack],
maxStickers: 3,
),
ExtraFieldAction.location(
icon: Icons.location_on_outlined,
onPickLocation: _simulateLocationPick,
),
],
onSend: (value) {
_showState();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sent!')),
);
_controller.reset();
},
),
onChanged: (_) => _showState(),
),
),
),
],
),
);
}
Widget _actionButton(String label, VoidCallback onPressed) {
return OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
textStyle: const TextStyle(fontSize: 12),
),
child: Text(label),
);
}
}