extra_field 0.1.0 copy "extra_field: ^0.1.0" to clipboard
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.

example/lib/main.dart

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),
    );
  }
}
0
likes
150
points
68
downloads
screenshot

Publisher

unverified uploader

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#input #composer #chat #mentions #widget

Documentation

API reference

License

MIT (license)

Dependencies

flutter

More

Packages that depend on extra_field