bluesky_text_flutter 0.1.1 copy "bluesky_text_flutter: ^0.1.1" to clipboard
bluesky_text_flutter: ^0.1.1 copied to clipboard

Flutter widgets for bluesky_text — a rich-text editing controller that highlights entities and the over-limit tail as you type, and a viewer for fetched posts.

example/lib/main.dart

// Copyright (c) 2023-2025, Shinya Kato.
// All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:bluesky_text_flutter/bluesky_text_flutter.dart';
import 'package:flutter/material.dart';

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

/// The signed-in user, and the authors of the sample timeline.
class _User {
  const _User(this.name, this.handle, this.color);
  final String name;
  final String handle;
  final Color color;
}

class _Post {
  const _Post(this.author, this.text, this.time);
  final _User author;
  final String text;
  final String time;
}

const _me = _User('You', 'you.bsky.social', Color(0xFF7C4DFF));

final _seedTimeline = <_Post>[
  const _Post(
    _User('Shinya Kato', 'shinyakato.dev', Color(0xFF1DA1F2)),
    'gm @bsky.app — shipping bluesky_text_flutter today 🦋 '
        'see https://atprotodart.com #atproto #dart',
    '2h',
  ),
  const _Post(
    _User('Bluesky', 'bsky.app', Color(0xFF0A7AFF)),
    'Rich text is just facets: byte ranges + features. Tap a mention like '
        '@atproto.com or a #tag to try it out.',
    '5h',
  ),
  const _Post(
    _User('AT Protocol', 'atproto.com', Color(0xFF10A37F)),
    'Facets use UTF-8 byteStart/byteEnd. bluesky_text does the '
        'UTF-8 ↔ UTF-16 conversion so your spans line up: '
        'https://atproto.com/specs/facets',
    '1d',
  ),
];

/// Derives display facets from [text] using bluesky_text, so the byte offsets
/// are always correct. In a real client these come from the API instead
/// (`PostFacet.fromJson`); a mention's DID would be resolved server-side.
List<PostFacet> _facetsOf(String text) => [
  for (final entity in BlueskyText(text).entities)
    PostFacet(
      byteStart: entity.indices.start,
      byteEnd: entity.indices.end,
      features: [
        FacetFeature(
          type: entity.type,
          value: entity.isHandle ? 'did:plc:${entity.value}' : entity.value,
        ),
      ],
    ),
];

/// The root of the demo app.
class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
    debugShowCheckedModeBanner: false,
    title: 'bluesky_text_flutter',
    theme: ThemeData(
      colorSchemeSeed: const Color(0xFF0A7AFF),
      useMaterial3: true,
    ),
    home: const _Feed(),
  );
}

class _Feed extends StatefulWidget {
  const _Feed();

  @override
  State<_Feed> createState() => _FeedState();
}

class _FeedState extends State<_Feed> {
  final _controller = BlueskyTextEditingController();
  final _timeline = List<_Post>.of(_seedTimeline);

  @override
  void initState() {
    super.initState();
    //* Rebuild so the live preview and counter follow every keystroke.
    _controller.addListener(() => setState(() {}));
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _post() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;
    setState(() {
      _timeline.insert(0, _Post(_me, text, 'now'));
      _controller.clear();
    });
  }

  void _onFeatureTap(FacetFeature feature) {
    final message = switch (feature.type) {
      EntityType.handle => 'Open profile · ${feature.value}',
      EntityType.link => 'Open link · ${feature.value}',
      EntityType.tag || EntityType.cashtag => 'Search · #${feature.value}',
      EntityType.markdownLink => feature.value,
    };
    ScaffoldMessenger.of(context)
      ..clearSnackBars()
      ..showSnackBar(
        SnackBar(content: Text(message), duration: const Duration(seconds: 1)),
      );
  }

  @override
  Widget build(BuildContext context) {
    final overflow = _controller.overflow;
    final canPost = _controller.text.trim().isNotEmpty && overflow == null;

    return Scaffold(
      appBar: AppBar(title: const Text('🦋  Home'), centerTitle: false),
      body: ListView(
        children: [
          _Composer(
            controller: _controller,
            overflow: overflow,
            canPost: canPost,
            onPost: _post,
          ),
          _PreviewSection(text: _controller.text, onFeatureTap: _onFeatureTap),
          const _SectionHeader('Timeline'),
          for (final post in _timeline)
            _PostTile(post: post, onFeatureTap: _onFeatureTap),
        ],
      ),
    );
  }
}

/// The compose box: avatar + a live-highlighting field + counter + Post button.
class _Composer extends StatelessWidget {
  const _Composer({
    required this.controller,
    required this.overflow,
    required this.canPost,
    required this.onPost,
  });

  final BlueskyTextEditingController controller;
  final TextLengthOverflow? overflow;
  final bool canPost;
  final VoidCallback onPost;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Container(
      padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
      decoration: BoxDecoration(
        border: Border(
          bottom: BorderSide(color: theme.colorScheme.outlineVariant),
        ),
      ),
      child: Column(
        children: [
          Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const _Avatar(_me),
              const SizedBox(width: 12),
              Expanded(
                child: TextField(
                  controller: controller,
                  maxLines: null,
                  minLines: 1,
                  keyboardType: TextInputType.multiline,
                  style: theme.textTheme.bodyLarge,
                  decoration: const InputDecoration.collapsed(
                    hintText: "What's up?",
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              const Spacer(),
              _Counter(controller: controller, overflow: overflow),
              const SizedBox(width: 12),
              FilledButton(
                onPressed: canPost ? onPost : null,
                child: const Text('Post'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

/// A Bluesky-style circular remaining-characters indicator.
class _Counter extends StatelessWidget {
  const _Counter({required this.controller, required this.overflow});

  final BlueskyTextEditingController controller;
  final TextLengthOverflow? overflow;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final length = BlueskyText(controller.text).length;
    final remaining = 300 - length;
    final over = overflow != null;

    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        if (over || remaining <= 20)
          Padding(
            padding: const EdgeInsets.only(right: 6),
            child: Text(
              '$remaining',
              style: theme.textTheme.bodySmall?.copyWith(
                color: over
                    ? theme.colorScheme.error
                    : theme.colorScheme.onSurfaceVariant,
                fontWeight: over ? FontWeight.bold : FontWeight.normal,
              ),
            ),
          ),
        SizedBox(
          width: 22,
          height: 22,
          child: CircularProgressIndicator(
            value: over ? 1 : (length / 300).clamp(0.0, 1.0),
            strokeWidth: 2.5,
            color: over ? theme.colorScheme.error : theme.colorScheme.primary,
            backgroundColor: theme.colorScheme.outlineVariant,
          ),
        ),
      ],
    );
  }
}

/// Shows how the composed text will render, updating on every keystroke.
class _PreviewSection extends StatelessWidget {
  const _PreviewSection({required this.text, required this.onFeatureTap});

  final String text;
  final FeatureTapCallback onFeatureTap;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final trimmed = text.trim();

    return Container(
      color: theme.colorScheme.surfaceContainerHighest,
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(
                Icons.visibility_outlined,
                size: 15,
                color: theme.colorScheme.onSurfaceVariant,
              ),
              const SizedBox(width: 6),
              Text(
                'LIVE PREVIEW',
                style: theme.textTheme.labelSmall?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                  letterSpacing: 0.8,
                ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          if (trimmed.isEmpty)
            Padding(
              padding: const EdgeInsets.symmetric(vertical: 8),
              child: Text(
                'Start typing above to see how your post will look — mentions, '
                'links and tags become tappable, and anything past the limit '
                'turns red.',
                style: theme.textTheme.bodyMedium?.copyWith(
                  color: theme.colorScheme.onSurfaceVariant,
                  fontStyle: FontStyle.italic,
                ),
              ),
            )
          else
            Card(
              margin: EdgeInsets.zero,
              child: _PostTile(
                post: _Post(_me, text, 'now'),
                onFeatureTap: onFeatureTap,
                divider: false,
              ),
            ),
        ],
      ),
    );
  }
}

/// One post row: avatar, name/handle/time header, rich text, action bar.
class _PostTile extends StatelessWidget {
  const _PostTile({
    required this.post,
    required this.onFeatureTap,
    this.divider = true,
  });

  final _Post post;
  final FeatureTapCallback onFeatureTap;
  final bool divider;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final muted = theme.textTheme.bodyMedium?.copyWith(
      color: theme.colorScheme.onSurfaceVariant,
    );

    return Container(
      padding: const EdgeInsets.all(12),
      decoration: divider
          ? BoxDecoration(
              border: Border(
                bottom: BorderSide(color: theme.colorScheme.outlineVariant),
              ),
            )
          : null,
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _Avatar(post.author),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Flexible(
                      child: Text(
                        post.author.name,
                        style: theme.textTheme.bodyMedium?.copyWith(
                          fontWeight: FontWeight.bold,
                        ),
                        overflow: TextOverflow.ellipsis,
                      ),
                    ),
                    const SizedBox(width: 4),
                    Flexible(
                      child: Text(
                        '@${post.author.handle}',
                        style: muted,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ),
                    Text(' · ${post.time}', style: muted),
                  ],
                ),
                const SizedBox(height: 2),
                BlueskyRichText(
                  text: post.text,
                  facets: _facetsOf(post.text),
                  style: theme.textTheme.bodyMedium,
                  featureStyle: TextStyle(color: theme.colorScheme.primary),
                  onFeatureTap: onFeatureTap,
                ),
                const SizedBox(height: 8),
                Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    _action(theme, Icons.chat_bubble_outline, '12'),
                    _action(theme, Icons.repeat, '4'),
                    _action(theme, Icons.favorite_border, '87'),
                    _action(theme, Icons.share_outlined, null),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _action(ThemeData theme, IconData icon, String? count) {
    final color = theme.colorScheme.onSurfaceVariant;
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Icon(icon, size: 17, color: color),
        if (count != null) ...[
          const SizedBox(width: 6),
          Text(count, style: theme.textTheme.bodySmall?.copyWith(color: color)),
        ],
      ],
    );
  }
}

class _Avatar extends StatelessWidget {
  const _Avatar(this.user);
  final _User user;

  @override
  Widget build(BuildContext context) => CircleAvatar(
    radius: 20,
    backgroundColor: user.color,
    child: Text(
      user.name.isEmpty ? '?' : user.name.characters.first.toUpperCase(),
      style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
    ),
  );
}

class _SectionHeader extends StatelessWidget {
  const _SectionHeader(this.title);
  final String title;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.fromLTRB(12, 16, 12, 8),
      child: Text(
        title,
        style: theme.textTheme.titleSmall?.copyWith(
          color: theme.colorScheme.onSurfaceVariant,
        ),
      ),
    );
  }
}
0
likes
160
points
250
downloads

Documentation

API reference

Publisher

verified publisheratprotodart.com

Weekly Downloads

Flutter widgets for bluesky_text — a rich-text editing controller that highlights entities and the over-limit tail as you type, and a viewer for fetched posts.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#atproto #bluesky #flutter #richtext

Funding

Consider supporting this project:

github.com

License

BSD-3-Clause (license)

Dependencies

bluesky_text, flutter

More

Packages that depend on bluesky_text_flutter