streaming_markdown 0.0.7 copy "streaming_markdown: ^0.0.7" to clipboard
streaming_markdown: ^0.0.7 copied to clipboard

A Flutter package for animated markdown rendering with ChatGPT-style typing animation, customizable speed, and extensible widget system.

example/lib/main.dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:streaming_markdown/streaming_markdown.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Animated Markdown Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MarkdownDemoPage(),
    );
  }
}

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

  @override
  State<MarkdownDemoPage> createState() => _MarkdownDemoPageState();
}

class _MarkdownDemoPageState extends State<MarkdownDemoPage> {
  AnimationMode _selectedMode = AnimationMode.word;
  AnimationConfig? _selectedPreset;
  int _buttonPressCount = 0;
  final List<String> _pressedChips = [];
  int _selectedTab = 0;
  bool _shouldAnimate = true;

  final String _exampleMarkdown = '''
# Welcome to Animated Markdown! 🎉

This package provides **ChatGPT-style typing animation** for markdown content.

## Features

- ✨ Smooth typing animation
- 🎨 Multiple animation modes
- 🧩 Custom inline widgets
- 📝 Full markdown support

## Inline Widgets

You can use inline widgets like buttons and chips:

Click [[button:Click Me]] to see it in action!

Here are some tags: [[chip:Important]] [[chip:New]] [[chip:Featured]]

You can also show sources: [[source:Documentation]] [[source:GitHub]]

## Inline Widgets in Lists

Inline widgets work seamlessly within list items:

- First item with [[button:Action]] button
- Second item with [[chip:Tag]] chip
- Third item with both [[chip:First]] and [[button:Second]] widgets
  - Nested item with [[chip:Nested]] chip
  - Another nested with [[button:Nested Button]] button

## Ordered Lists

1. Item one with [[button:Click]] button
2. Item two with [[chip:Label]] chip
3. Item three with multiple widgets: [[chip:Tag1]] and [[chip:Tag2]]

## Code Example

Here's some code:

\`\`\`dart
AnimatedMarkdown(
  markdown: 'Hello [[button:World]]!',
  customBuilders: {
    'button': ButtonBuilder(),
  },
)
\`\`\`

## Math (Placeholder)

Math formula: [[math:E = mc²]]

## Source Attribution

Content from [[source:Research Paper]] and [[source:Official Docs]] can be cited inline.

## Citations

You can also use citation references like [1] and [2] with custom regex patterns!

This is a sentence with a citation [1] and another one [2]. Citations are clickable!

Enjoy using animated markdown! 🚀
''';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Animated Markdown Demo'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _buildTabBar(),
          _buildControls(),
          Expanded(
            child: SingleChildScrollView(
              padding: const EdgeInsets.all(
                16,
              ).copyWith(bottom: MediaQuery.of(context).padding.bottom + 16),
              child: _selectedTab == 0
                  ? _buildStaticMarkdown()
                  : _selectedTab == 1
                  ? _buildStreamMarkdown()
                  : _buildLargeChunkStream(),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildTabBar() {
    return Container(
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
        boxShadow: [
          BoxShadow(
            color: Colors.black.withValues(alpha: 0.1),
            blurRadius: 2,
            offset: const Offset(0, 1),
          ),
        ],
      ),
      child: Row(
        children: [
          Expanded(
            child: _buildTabButton(0, 'Static Markdown', Icons.text_fields),
          ),
          Expanded(child: _buildTabButton(1, 'Stream Example', Icons.stream)),
          Expanded(
            child: _buildTabButton(2, 'Large Chunk Stream', Icons.data_usage),
          ),
        ],
      ),
    );
  }

  Widget _buildTabButton(int index, String label, IconData icon) {
    final bool isSelected = _selectedTab == index;
    return InkWell(
      onTap: () {
        setState(() {
          _selectedTab = index;
        });
      },
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 12),
        decoration: BoxDecoration(
          color: isSelected
              ? Theme.of(context).colorScheme.primaryContainer
              : Colors.transparent,
          border: Border(
            bottom: BorderSide(
              color: isSelected
                  ? Theme.of(context).colorScheme.primary
                  : Colors.transparent,
              width: 2,
            ),
          ),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(icon, size: 18),
            const SizedBox(width: 8),
            Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
          ],
        ),
      ),
    );
  }

  Widget _buildStaticMarkdown() {
    return AnimatedMarkdown(
      markdown: _exampleMarkdown,
      shouldAnimate: _shouldAnimate,
      styleSheet: MarkdownStyleSheet().copyWith(
        p: TextStyle(color: Colors.purple),
      ),
      config:
          _selectedPreset ??
          AnimationConfig(
            mode: _selectedMode,
            charDelay: const Duration(milliseconds: 10),
            wordDelay: const Duration(milliseconds: 50),
            tokenDelay: const Duration(milliseconds: 30),
            chunkSize: 1,
          ),
      customBuilders: {
        'button': ButtonBuilder(
          onPressed: (label) {
            setState(() {
              _buttonPressCount++;
            });
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text(
                  'Button "$label" pressed! (Count: $_buttonPressCount)',
                ),
                duration: const Duration(seconds: 1),
              ),
            );
          },
        ),
        'chip': ChipBuilder(
          onPressed: (label) {
            setState(() {
              if (_pressedChips.contains(label)) {
                _pressedChips.remove(label);
              } else {
                _pressedChips.add(label);
              }
            });
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text(
                  _pressedChips.contains(label)
                      ? 'Chip "$label" selected!'
                      : 'Chip "$label" deselected!',
                ),
                duration: const Duration(seconds: 1),
              ),
            );
          },
        ),
        'math': MathBuilder(),
        'source': SourceBuilder(),
        'citation': CitationBuilder(
          onPressed: (citationNumber) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text(
                  'Citation [$citationNumber] clicked! Reference: Source $citationNumber',
                ),
                duration: const Duration(seconds: 2),
              ),
            );
          },
        ),
      },
      customSyntaxPatterns: {'citation': r'\[(\d+)\]'},
      onAnimationComplete: (String finalText) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text('Animation completed!'),
            duration: Duration(seconds: 2),
          ),
        );
      },
    );
  }

  Widget _buildStreamMarkdown() {
    return StreamMarkdownExample(
      shouldAnimate: _shouldAnimate,

      config:
          _selectedPreset ??
          AnimationConfig(
            mode: _selectedMode,
            charDelay: const Duration(milliseconds: 10),
            wordDelay: const Duration(milliseconds: 50),
            tokenDelay: const Duration(milliseconds: 30),
          ),
      onButtonPressed: (label) {
        setState(() {
          _buttonPressCount++;
        });
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(
              'Button "$label" pressed! (Count: $_buttonPressCount)',
            ),
            duration: const Duration(seconds: 1),
          ),
        );
      },
      onChipPressed: (label) {
        setState(() {
          if (_pressedChips.contains(label)) {
            _pressedChips.remove(label);
          } else {
            _pressedChips.add(label);
          }
        });
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(
              _pressedChips.contains(label)
                  ? 'Chip "$label" selected!'
                  : 'Chip "$label" deselected!',
            ),
            duration: const Duration(seconds: 1),
          ),
        );
      },
    );
  }

  Widget _buildLargeChunkStream() {
    return LargeChunkStreamExample(
      shouldAnimate: _shouldAnimate,
      config:
          _selectedPreset ??
          AnimationConfig(
            mode: _selectedMode,
            charDelay: const Duration(milliseconds: 10),
            wordDelay: const Duration(milliseconds: 50),
            tokenDelay: const Duration(milliseconds: 30),
            chunkSize: 1,
          ),
      onButtonPressed: (label) {
        setState(() {
          _buttonPressCount++;
        });
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(
              'Button "$label" pressed! (Count: $_buttonPressCount)',
            ),
            duration: const Duration(seconds: 1),
          ),
        );
      },
      onChipPressed: (label) {
        setState(() {
          if (_pressedChips.contains(label)) {
            _pressedChips.remove(label);
          } else {
            _pressedChips.add(label);
          }
        });
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(
              _pressedChips.contains(label)
                  ? 'Chip "$label" selected!'
                  : 'Chip "$label" deselected!',
            ),
            duration: const Duration(seconds: 1),
          ),
        );
      },
    );
  }

  Widget _buildControls() {
    return SizedBox(
      width: double.infinity,
      child: Container(
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: Theme.of(context).colorScheme.surfaceContainerHighest,
          boxShadow: [
            BoxShadow(
              color: Colors.black.withValues(alpha: 0.1),
              blurRadius: 4,
              offset: const Offset(0, 2),
            ),
          ],
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Expanded(
                  child: Text(
                    'Preset Config',
                    style: Theme.of(context).textTheme.titleSmall,
                  ),
                ),
                Row(
                  children: [
                    Text(
                      'Animate',
                      style: Theme.of(context).textTheme.titleSmall,
                    ),
                    const SizedBox(width: 8),
                    Switch(
                      value: _shouldAnimate,
                      onChanged: (value) {
                        setState(() {
                          _shouldAnimate = value;
                        });
                      },
                    ),
                  ],
                ),
              ],
            ),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              children: [
                _buildPresetChip(null, 'Custom'),
                _buildPresetChip(AnimationConfig.chatGPT, 'ChatGPT'),
                _buildPresetChip(AnimationConfig.claude, 'Claude'),
                _buildPresetChip(AnimationConfig.grok, 'Grok'),
                _buildPresetChip(AnimationConfig.perplexity, 'Perplexity'),
                _buildPresetChip(AnimationConfig.gemini, 'Gemini'),
                _buildPresetChip(AnimationConfig.copilot, 'Copilot'),
              ],
            ),
            if (_selectedPreset == null) ...[
              const SizedBox(height: 16),
              Text(
                'Animation Mode',
                style: Theme.of(context).textTheme.titleSmall,
              ),
              const SizedBox(height: 8),
              Wrap(
                spacing: 8,
                children: [
                  _buildModeChip(AnimationMode.character, 'Character'),
                  _buildModeChip(AnimationMode.word, 'Word'),
                  _buildModeChip(AnimationMode.token, 'Token'),
                  _buildModeChip(AnimationMode.custom, 'Custom'),
                ],
              ),
            ],
            if (_buttonPressCount > 0 || _pressedChips.isNotEmpty) ...[
              const SizedBox(height: 16),
              Text(
                'Interactions',
                style: Theme.of(context).textTheme.titleSmall,
              ),
              const SizedBox(height: 8),
              if (_buttonPressCount > 0)
                Text('Button presses: $_buttonPressCount'),
              if (_pressedChips.isNotEmpty)
                Text('Selected chips: ${_pressedChips.join(", ")}'),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildModeChip(AnimationMode mode, String label) {
    final bool isSelected = _selectedMode == mode;
    return FilterChip(
      label: Text(label),
      selected: isSelected,
      onSelected: (selected) {
        if (selected) {
          setState(() {
            _selectedMode = mode;
          });
        }
      },
    );
  }

  Widget _buildPresetChip(AnimationConfig? preset, String label) {
    final bool isSelected = _selectedPreset == preset;
    return FilterChip(
      label: Text(label),
      selected: isSelected,
      onSelected: (selected) {
        if (selected) {
          setState(() {
            _selectedPreset = preset;
            if (preset != null) {
              _selectedMode = preset.mode;
            }
          });
        }
      },
    );
  }
}

/// Builder for inline citation widgets: [1], [2], etc.
class CitationBuilder extends InlineBuilder {
  /// Callback when citation is pressed.
  final void Function(String citationNumber)? onPressed;

  /// Citation color.
  final Color? color;

  /// Creates a [CitationBuilder] with optional callback and color.
  CitationBuilder({this.onPressed, this.color});

  @override
  Widget buildInline(md.Element element, TextStyle? style) {
    final String citationNumber = element.textContent;

    final Widget citationWidget = GestureDetector(
      onTap: onPressed != null ? () => onPressed!(citationNumber) : null,
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
        decoration: BoxDecoration(
          color: (color ?? Colors.blue).withValues(alpha: 0.1),
          borderRadius: BorderRadius.circular(4),
          border: Border.all(color: color ?? Colors.blue, width: 1),
        ),
        child: Text(
          '[$citationNumber]',
          style: (style ?? const TextStyle()).copyWith(
            color: color ?? Colors.blue,
            fontSize: (style?.fontSize ?? 14) * 0.85,
            fontWeight: FontWeight.w600,
          ),
        ),
      ),
    );
    return Text.rich(
      TextSpan(
        children: [
          WidgetSpan(
            alignment: PlaceholderAlignment.middle,
            child: citationWidget,
          ),
        ],
      ),
      style: style,
    );
  }
}

/// Builder for inline source widgets: [[source:Source Name]]
class SourceBuilder extends InlineBuilder {
  /// Source color.
  final Color? color;

  /// Creates a [SourceBuilder] with optional color.
  SourceBuilder({this.color});

  @override
  Widget buildInline(md.Element element, TextStyle? style) {
    final String source = element.textContent;

    return Builder(
      builder: (BuildContext context) {
        return Padding(
          padding: const EdgeInsets.symmetric(horizontal: 4.0),
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
            decoration: BoxDecoration(
              color: (color ?? Colors.blue).withValues(alpha: 0.1),
              borderRadius: BorderRadius.circular(12),
              border: Border.all(color: color ?? Colors.blue, width: 1),
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(Icons.source, size: 14, color: color ?? Colors.blue),
                const SizedBox(width: 4),
                Text(
                  source,
                  style: style?.copyWith(
                    color: color ?? Colors.blue,
                    fontSize: 12,
                    fontWeight: FontWeight.w500,
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

class StreamMarkdownExample extends StatefulWidget {
  final AnimationConfig config;
  final bool shouldAnimate;
  final void Function(String) onButtonPressed;
  final void Function(String) onChipPressed;

  const StreamMarkdownExample({
    super.key,
    required this.config,
    this.shouldAnimate = true,
    required this.onButtonPressed,
    required this.onChipPressed,
  });

  @override
  State<StreamMarkdownExample> createState() => _StreamMarkdownExampleState();
}

class _StreamMarkdownExampleState extends State<StreamMarkdownExample> {
  StreamController<String>? _streamController;
  int _streamKey = 0;
  Stream<String>? _currentStream;

  final String _streamExampleContent = '''
# Streaming Markdown Example 📡

This demonstrates how **AnimatedMarkdown** handles streaming content in real-time.

## How It Works

The content below is being streamed chunk by chunk, simulating a real-world scenario like:

- API responses
- WebSocket messages
- Server-sent events
- Large file processing

## Features Demonstrated

- ✨ **Real-time streaming**: Content appears as it arrives
- 🔄 **Continuous animation**: Animation continues seamlessly as new chunks arrive
- 📦 **Large chunk handling**: Handles both small and large data chunks
- ⚡ **Non-blocking**: UI remains responsive during streaming

## Example Content

This is the first chunk of content. Notice how the animation continues smoothly.

Here comes more content! The animation doesn't stop or restart.

### Lists Work Too

- First streaming item
- Second streaming item
- Third streaming item with [[chip:Streaming]] support

### Code Blocks

\`\`\`dart
// This code block is streamed in chunks
Stream<String> markdownStream = getMarkdownStream();
AnimatedMarkdown(
  stream: markdownStream,
  config: AnimationConfig.chatGPT,
)
\`\`\`

## Interactive Elements

You can even stream interactive elements: [[button:Streamed Button]]

And chips work too: [[chip:Streamed]] [[chip:Content]]

Sources work in streams too: [[source:API Response]] [[source:WebSocket]]

Citations also work in streams: This is streamed content with citation [1] and [2].

## Final Thoughts

The stream continues to work even with large amounts of content. The animation adapts and continues smoothly, making it perfect for real-time applications!

🎉 **Streaming complete!**
''';

  Stream<String> _createMarkdownStream() {
    _streamController?.close();
    _streamController = StreamController<String>.broadcast();
    final List<String> chunks = _splitIntoChunks(_streamExampleContent);
    _streamChunks(chunks, 0);
    return _streamController!.stream;
  }

  void _streamChunks(List<String> chunks, int index) {
    if (index >= chunks.length) {
      _streamController?.close();
      return;
    }
    Future.delayed(Duration(milliseconds: 100 + (index % 5) * 50), () {
      if (_streamController != null && !_streamController!.isClosed) {
        _streamController!.add(chunks[index]);
        _streamChunks(chunks, index + 1);
      }
    });
  }

  List<String> _splitIntoChunks(String text) {
    final List<String> chunks = [];
    final int chunkSize = 50;
    for (int i = 0; i < text.length; i += chunkSize) {
      final int end = (i + chunkSize < text.length)
          ? i + chunkSize
          : text.length;
      chunks.add(text.substring(i, end));
    }
    return chunks;
  }

  void _restartStream() {
    setState(() {
      _streamController?.close();
      _streamController = null;
      _currentStream = null;
      _streamKey++;
    });
  }

  Stream<String> _getOrCreateStream() {
    _currentStream ??= _createMarkdownStream();
    return _currentStream!;
  }

  @override
  void dispose() {
    _streamController?.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.only(bottom: 16),
          child: ElevatedButton.icon(
            onPressed: _restartStream,
            icon: const Icon(Icons.refresh),
            label: const Text('Restart Stream'),
          ),
        ),
        AnimatedMarkdown(
          key: ValueKey<int>(_streamKey),
          stream: _getOrCreateStream(),
          styleSheet: MarkdownStyleSheet().copyWith(
            p: TextStyle(color: Colors.red),
          ),
          shouldAnimate: widget.shouldAnimate,
          config: widget.config,
          customBuilders: {
            'button': ButtonBuilder(onPressed: widget.onButtonPressed),
            'chip': ChipBuilder(onPressed: widget.onChipPressed),
            'math': MathBuilder(),
            'source': SourceBuilder(),
            'citation': CitationBuilder(
              onPressed: (citationNumber) {
                if (mounted) {
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(
                      content: Text(
                        'Citation [$citationNumber] clicked! Reference: Source $citationNumber',
                      ),
                      duration: const Duration(seconds: 2),
                    ),
                  );
                }
              },
            ),
          },
          customSyntaxPatterns: {'citation': r'\[(\d+)\]'},
          onAnimationComplete: (String finalText) {
            if (mounted) {
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(
                  content: Text('Stream animation completed!'),
                  duration: Duration(seconds: 2),
                ),
              );
            }
          },
        ),
      ],
    );
  }
}

class LargeChunkStreamExample extends StatefulWidget {
  final AnimationConfig config;
  final bool shouldAnimate;
  final void Function(String) onButtonPressed;
  final void Function(String) onChipPressed;

  const LargeChunkStreamExample({
    super.key,
    required this.config,
    this.shouldAnimate = true,
    required this.onButtonPressed,
    required this.onChipPressed,
  });

  @override
  State<LargeChunkStreamExample> createState() =>
      _LargeChunkStreamExampleState();
}

class _LargeChunkStreamExampleState extends State<LargeChunkStreamExample> {
  StreamController<String>? _streamController;
  int _streamKey = 0;
  Stream<String>? _currentStream;

  final String _streamExampleContent = '''
# Streaming Markdown Example 📡

This demonstrates how **AnimatedMarkdown** handles streaming content in real-time.

## How It Works

The content below is being streamed chunk by chunk, simulating a real-world scenario like:

- API responses
- WebSocket messages
- Server-sent events
- Large file processing

## Features Demonstrated

- ✨ **Real-time streaming**: Content appears as it arrives
- 🔄 **Continuous animation**: Animation continues seamlessly as new chunks arrive
- 📦 **Large chunk handling**: Handles both small and large data chunks
- ⚡ **Non-blocking**: UI remains responsive during streaming

## Example Content

This is the first chunk of content. Notice how the animation continues smoothly.

Here comes more content! The animation doesn't stop or restart.

### Lists Work Too

- First streaming item
- Second streaming item
- Third streaming item with [[chip:Streaming]] support

### Code Blocks

\`\`\`dart
// This code block is streamed in chunks
Stream<String> markdownStream = getMarkdownStream();
AnimatedMarkdown(
  stream: markdownStream,
  config: AnimationConfig.chatGPT,
)
\`\`\`

## Interactive Elements

You can even stream interactive elements: [[button:Streamed Button]]

And chips work too: [[chip:Streamed]] [[chip:Content]]

Sources work in streams too: [[source:API Response]] [[source:WebSocket]]

Citations also work in streams: This is streamed content with citation [1] and [2].

## Final Thoughts

The stream continues to work even with large amounts of content. The animation adapts and continues smoothly, making it perfect for real-time applications!

🎉 **Streaming complete!**
''';

  Stream<String> _createMarkdownStream() {
    _streamController?.close();
    _streamController = StreamController<String>.broadcast();
    final List<String> chunks = _splitIntoLargeChunks(_streamExampleContent);
    _streamLargeChunks(chunks, 0);
    return _streamController!.stream;
  }

  void _streamLargeChunks(List<String> chunks, int index) {
    if (index >= chunks.length) {
      _streamController?.close();
      return;
    }
    final Duration delay = index == 0
        ? Duration.zero
        : const Duration(seconds: 3);
    Future.delayed(delay, () {
      if (_streamController != null && !_streamController!.isClosed) {
        _streamController!.add(chunks[index]);
        _streamLargeChunks(chunks, index + 1);
      }
    });
  }

  List<String> _splitIntoLargeChunks(String text) {
    final int splitPoint = (text.length * 0.6).round();
    int bestSplitPoint = splitPoint;
    for (int i = splitPoint - 50; i < splitPoint + 50 && i < text.length; i++) {
      if (i > 0 &&
          text[i] == '\n' &&
          (i == text.length - 1 || text[i + 1] == '\n')) {
        bestSplitPoint = i + 1;
        break;
      }
    }
    return [text.substring(0, bestSplitPoint), text.substring(bestSplitPoint)];
  }

  void _restartStream() {
    setState(() {
      _streamController?.close();
      _streamController = null;
      _currentStream = null;
      _streamKey++;
    });
  }

  Stream<String> _getOrCreateStream() {
    _currentStream ??= _createMarkdownStream();
    return _currentStream!;
  }

  @override
  void dispose() {
    _streamController?.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.only(bottom: 16),
          child: ElevatedButton.icon(
            onPressed: _restartStream,
            icon: const Icon(Icons.refresh),
            label: const Text('Restart Stream'),
          ),
        ),
        AnimatedMarkdown(
          key: ValueKey<int>(_streamKey),
          stream: _getOrCreateStream(),
          styleSheet: MarkdownStyleSheet().copyWith(
            p: TextStyle(color: Colors.red),
          ),
          shouldAnimate: widget.shouldAnimate,
          config: widget.config,
          customBuilders: {
            'button': ButtonBuilder(onPressed: widget.onButtonPressed),
            'chip': ChipBuilder(onPressed: widget.onChipPressed),
            'math': MathBuilder(),
            'source': SourceBuilder(),
            'citation': CitationBuilder(
              onPressed: (citationNumber) {
                if (mounted) {
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(
                      content: Text(
                        'Citation [$citationNumber] clicked! Reference: Source $citationNumber',
                      ),
                      duration: const Duration(seconds: 2),
                    ),
                  );
                }
              },
            ),
          },
          customSyntaxPatterns: {'citation': r'\[(\d+)\]'},
          onAnimationComplete: (String finalText) {
            if (mounted) {
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(
                  content: Text('Large chunk stream animation completed!'),
                  duration: Duration(seconds: 2),
                ),
              );
            }
          },
        ),
      ],
    );
  }
}
1
likes
160
points
16
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter package for animated markdown rendering with ChatGPT-style typing animation, customizable speed, and extensible widget system.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, flutter_markdown_plus, markdown

More

Packages that depend on streaming_markdown