flutter_taggable 0.0.1 copy "flutter_taggable: ^0.0.1" to clipboard
flutter_taggable: ^0.0.1 copied to clipboard

A lightweight extension to TextEditingController that allows for tagging/mentioning users, with a focus on storing tags in a database-friendly format.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:taggable/taggable.dart';

class Taggable {
  const Taggable({required this.id, required this.name});

  final String id;
  final String name;
}

class User extends Taggable {
  const User({required super.id, required super.name});
}

class Topic extends Taggable {
  const Topic({required super.id, required super.name});
}

/// A list of users to search from.
const users = <User>[
  User(id: '1ax', name: 'Alice'),
  User(id: '2by', name: 'Bob'),
  User(id: '3cz', name: 'Charlie'),
  User(id: '4dw', name: 'Carol'),
];

/// A list of topics to search from.
const topics = <Topic>[
  Topic(id: 'myDartId', name: 'Dart'),
  Topic(id: 'myFlutterId', name: 'Flutter'),
  Topic(id: 'myPubId', name: 'Pub'),
];

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: Scaffold(body: HomePage()));
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  /// The [LayerLink] is used to link the [CompositedTransformTarget] and
  /// [CompositedTransformFollower] widgets required to show the overlay.
  final _layerLink = LayerLink();

  /// The [_formKey] is used to get the [RenderBox] of the [Form] widget to
  /// position the overlay.
  final _formKey = GlobalKey<FormState>();

  /// The [_focusNode] is used to focus the [TextField] when the overlay is
  /// closed.
  late FocusNode _focusNode;

  /// A list of comments made using the [TagTextEditingController].
  final List<List<InlineSpan>> comments = [];

  /// The [TagTextEditingController] is used to control the [TextField] and
  /// handle the tagging logic.
  late final TagTextEditingController _controller;

  /// The [_overlayEntry] is used to show the overlay with the list of
  /// taggables.
  OverlayEntry? _overlayEntry;

  /// The [backendFormat] is used to display the backend format of the text
  String backendFormat = '';

  @override
  void initState() {
    super.initState();
    _focusNode = FocusNode();

    // Initialize the [TagTextEditingController] with the required parameters.
    _controller = TagTextEditingController<Taggable>(
        searchTaggables: searchTaggables,
        buildTaggables: buildTaggables,
        toFrontendConverter: (taggable) => taggable.name,
        toBackendConverter: (taggable) => taggable.id,
        tagStyles: {
          '@': const TextStyle(color: Colors.blue),
          '#': const TextStyle(color: Colors.green),
          'all:': const TextStyle(color: Colors.purple),
        });

    // Add a listener to update the [backendFormat] when the text changes.
    _controller.addListener(() {
      setState(() {
        backendFormat = _controller.backendTextFormat;
      });
    });
  }

  @override
  void dispose() {
    _focusNode.dispose();
    _controller.dispose();
    _overlayEntry?.remove();
    super.dispose();
  }

  /// This method is used to build the [InlineSpan]s from the backend format.
  ///
  /// You can copy this method to your project and modify it as per your
  /// requirements.
  FutureOr<List<InlineSpan>> _buildTextSpans(
    String backendFormat, {
    TextStyle? defaultStyle,
  }) async {
    String? leadingSpace;
    List<InlineSpan> spans = [];
    for (String word in backendFormat.split(' ')) {
      leadingSpace = leadingSpace == null ? "" : " ";

      final tagPrefix = _controller.tagStyles.keys
          .where(
            (prefix) => word.startsWith(prefix),
          )
          .firstOrNull;
      if (tagPrefix == null) {
        spans.add(TextSpan(text: "$leadingSpace$word", style: defaultStyle));
        continue;
      }

      final taggable =
          await backendToTaggable(tagPrefix, word.substring(tagPrefix.length));

      if (taggable == null) {
        spans.add(TextSpan(text: "$leadingSpace$word", style: defaultStyle));
        continue;
      }

      final tagStyle = _controller.tagStyles[tagPrefix]!;
      final mergedStyle = defaultStyle?.merge(tagStyle) ?? tagStyle;

      spans.add(TextSpan(
        text: "$leadingSpace$tagPrefix${taggable.name}",
        style: mergedStyle,
        recognizer: TapGestureRecognizer()
          ..onTap = () => debugPrint('Tapped on ${taggable.name}'),
      ));
    }
    return spans;
  }

  /// Shows the overlay with the list of taggables.
  Future<Taggable?> buildTaggables(
      FutureOr<Iterable<Taggable>> taggables) async {
    final availableTaggables = await taggables;

    // We use a [Completer] to return the selected taggable from the overlay.
    // This is because overlays do not return values directly.
    Completer<Taggable?> completer = Completer();

    // Remove the existing overlay if it exists.
    _overlayEntry?.remove();
    if (availableTaggables.isEmpty) {
      // If there are no taggables to show, we return null.
      _overlayEntry = null;
      completer.complete(null);
    } else {
      _overlayEntry = OverlayEntry(builder: (context) {
        // The following few lines are used to position the overlay above the
        // [TextField]. It moves along if the [TextField] moves.
        final renderBox =
            _formKey.currentContext!.findRenderObject() as RenderBox;
        return Positioned(
          width: renderBox.size.width,
          bottom: renderBox.size.height + 8,
          child: CompositedTransformFollower(
            link: _layerLink,
            showWhenUnlinked: false,
            followerAnchor: Alignment.bottomLeft,
            child: Material(
              child: ListView(
                shrinkWrap: true,
                children: availableTaggables.map((taggable) {
                  // We show the list of taggables in a [ListView].
                  return ListTile(
                    title: Text(taggable.name),
                    tileColor: Theme.of(context).colorScheme.primaryContainer,
                    onTap: () {
                      // When a taggable is selected, we remove the overlay
                      _overlayEntry?.remove();
                      _overlayEntry = null;
                      // and complete the [Completer] with the selected taggable.
                      completer.complete(taggable);
                      // We also focus the [TextField] to continue typing.
                      // Do this after completing the [Completer] to avoid
                      // Messing up the logic of adding the taggable to the text.
                      _focusNode.requestFocus();
                    },
                  );
                }).toList(),
              ),
            ),
          ),
        );
      });
      if (mounted) {
        Overlay.of(context).insert(_overlayEntry!);
      }
    }
    return completer.future;
  }

  /// This method searches for taggables based on the tag prefix and tag name.
  /// 
  /// You can specify different behaviour based on the tag prefix.
  Future<Iterable<Taggable>> searchTaggables(
      String tagPrefix, String? tagName) async {
    if (tagName == null || tagName.isEmpty) {
      return [];
    }
    return switch (tagPrefix) {
      '@' => users
          .where((user) =>
              user.name.toLowerCase().startsWith(tagName.toLowerCase()))
          .toList(),
      '#' => topics
          .where((topic) =>
              topic.name.toLowerCase().startsWith(tagName.toLowerCase()))
          .toList(),
      'all:' => [...users, ...topics].where((taggable) =>
          taggable.name.toLowerCase().startsWith(tagName.toLowerCase())),
      _ => [],
    };
  }

  /// This method converts the backend format to the taggable object.
  FutureOr<Taggable?> backendToTaggable(String prefix, String id) {
    return switch (prefix) {
      '@' => users.where((user) => user.id == id).firstOrNull,
      '#' => topics.where((topic) => topic.id == id).firstOrNull,
      'all:' => [...users, ...topics]
          .where((taggable) => taggable.id == id)
          .firstOrNull,
      _ => null,
    };
  }

  @override
  Widget build(BuildContext context) {
    // Build a list of comments made using the [TagTextEditingController].
    // as well as a [TextField] to add new comments.
    return Center(
      child: SizedBox(
        width: 400,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            ...comments.map((comment) {
              return Card(
                margin: const EdgeInsets.all(4),
                child: Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Text.rich(
                    TextSpan(
                      children: comment,
                    ),
                  ),
                ),
              );
            }),
            Form(
              key: _formKey,
              child: CompositedTransformTarget(
                link: _layerLink,
                child: TextField(
                  controller: _controller,
                  focusNode: _focusNode,
                  decoration: InputDecoration(
                    hintText: 'Type @ to tag a user or # to tag a topic',
                    helperText: 'Backend format: $backendFormat',
                    suffixIcon: IconButton(
                      icon: const Icon(Icons.send),
                      onPressed: () async {
                        final textSpans = await _buildTextSpans(
                            _controller.backendTextFormat);
                        setState(() {
                          comments.add(textSpans);
                          _controller.clear();
                        });
                      },
                    ),
                  ),
                ),
              ),
            ),
            TextButton(
              onPressed: () {
                // This is an example of setting the initial text.
                _controller.setInitialText(
                  "Hello @1ax and welcome to #myFlutterId",
                  backendToTaggable,
                );
              },
              child: const Text('Set initial text'),
            ),
          ],
        ),
      ),
    );
  }
}
5
likes
0
points
134
downloads

Publisher

verified publisherwesselvandam.nl

Weekly Downloads

A lightweight extension to TextEditingController that allows for tagging/mentioning users, with a focus on storing tags in a database-friendly format.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter

More

Packages that depend on flutter_taggable