home / skills / rodydavis / skills / snippets_flutter-input-output-preview

snippets_flutter-input-output-preview skill

/skills/snippets_flutter-input-output-preview

This skill helps you build responsive Flutter code editors with a reusable TwoPane and InputOutputPreview to render code and live previews side by side.

npx playbooks add skill rodydavis/skills --skill snippets_flutter-input-output-preview

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
9.0 KB
---
name: flutter-input-output-preview
description: Build responsive Flutter apps with a reusable `TwoPane` widget and an `InputOutputPreview` component for side-by-side code and preview display on both mobile and desktop.
metadata:
  url: https://rodydavis.com/posts/snippets/flutter-input-output-preview
  last_modified: Tue, 03 Feb 2026 20:04:31 GMT
---

# Flutter Input Output Preview


First we need a two pane widget to properly render on mobile and desktop:

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

class TwoPane extends StatefulWidget {
  const TwoPane({
    super.key,
    required this.primary,
    required this.secondary,
    required this.title,
    this.actions = const [],
    this.loading = false,
  });

  final (String, WidgetBuilder) primary, secondary;
  final List<Widget> actions;
  final String title;
  final bool loading;

  @override
  State<TwoPane> createState() => _TwoPaneState();
}

class _TwoPaneState extends State<TwoPane> {
  bool darkMode = false;

  void toggleDarkMode() {
    if (mounted) {
      setState(() {
        darkMode = !darkMode;
      });
    }
  }

  ThemeData theme(Color color, Brightness brightness) {
    return ThemeData(
      brightness: brightness,
      colorScheme: ColorScheme.fromSeed(
        seedColor: color,
        brightness: brightness,
      ),
      useMaterial3: true,
    );
  }

  @override
  void didUpdateWidget(covariant TwoPane oldWidget) {
    if (oldWidget.loading != widget.loading ||
        oldWidget.title != widget.title ||
        oldWidget.actions != widget.actions) {
      if (mounted) setState(() {});
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: theme(Colors.purple, darkMode ? Brightness.dark : Brightness.light),
      child: Builder(builder: (context) {
        final (primaryTitle, primaryBuilder) = widget.primary;
        final (secondaryTitle, secondaryBuilder) = widget.secondary;
        return Scaffold(
            appBar: AppBar(
              title: Text(widget.title),
              centerTitle: false,
              actions: [
                IconButton(
                  tooltip: 'Toggle dark mode',
                  onPressed: toggleDarkMode,
                  icon: Icon(darkMode ? Icons.light_mode : Icons.dark_mode),
                ),
                ...widget.actions,
              ],
            ),
            body: LayoutBuilder(
              builder: (context, dimens) {
                if (dimens.maxWidth > 800 && dimens.maxHeight > 600) {
                  return Column(
                    children: [
                      if (widget.loading) const LinearProgressIndicator(),
                      Expanded(
                        child: Row(
                          children: [
                            Flexible(
                              flex: 1,
                              child: primaryBuilder(context),
                            ),
                            Flexible(
                              flex: 1,
                              child: secondaryBuilder(context),
                            ),
                          ],
                        ),
                      ),
                    ],
                  );
                }
                return DefaultTabController(
                  length: 2,
                  child: Column(
                    children: [
                      SizedBox(
                        height: kToolbarHeight,
                        width: double.infinity,
                        child: TabBar(
                          tabs: [
                            Tab(text: primaryTitle),
                            Tab(text: secondaryTitle),
                          ],
                        ),
                      ),
                      if (widget.loading) const LinearProgressIndicator(),
                      Expanded(
                        child: TabBarView(
                          children: [
                            primaryBuilder(context),
                            secondaryBuilder(context),
                          ],
                        ),
                      ),
                    ],
                  ),
                );
              },
            ));
      }),
    );
  }
}
```

Then we can pass some text fields for one pane to render an output:

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

import 'two_pane.dart';

class InputOutputPreview extends StatefulWidget {
  const InputOutputPreview({
    super.key,
    required this.title,
    required this.input,
    required this.output,
    required this.preview,
    required this.placeholder,
    this.actions = const [],
    this.codeTitle = 'Code',
    this.previewTitle = 'Preview',
    this.loading = false,
    this.lazy = false,
    this.previewSize = const Size(300, 700),
  });

  final (
    String,
    ValueChanged<(TextEditingController, TextEditingController)>
  ) input, output;
  final Widget? preview;
  final Widget placeholder;
  final String title;
  final List<Widget> actions;
  final String codeTitle, previewTitle;
  final Size? previewSize;
  final bool loading;
  final bool lazy;

  @override
  State<InputOutputPreview> createState() => _InputOutputPreviewState();
}

class _InputOutputPreviewState extends State<InputOutputPreview> {
  final input = TextEditingController();
  final output = TextEditingController();
  String? lastInput;
  String? lastOutput;

  @override
  void initState() {
    super.initState();
    if (!widget.lazy) input.addListener(onInput);
    output.addListener(onOutput);
  }

  @override
  void dispose() {
    super.dispose();
    if (!widget.lazy) input.removeListener(onInput);
    output.removeListener(onOutput);
    input.dispose();
    output.dispose();
  }

  void onInput() {
    final (_, update) = widget.input;
    final str = input.text;
    if (lastInput == str) return;
    update((input, output));
    lastInput = str;
  }

  void onOutput() {
    final (_, update) = widget.output;
    final str = output.text;
    if (lastOutput == str) return;
    update((output, input));
    lastOutput = str;
  }

  @override
  Widget build(BuildContext context) {
    final (inputTitle, _) = widget.input;
    final (outputTitle, _) = widget.output;
    return TwoPane(
      title: widget.title,
      actions: widget.actions,
      loading: widget.loading,
      primary: (
        widget.codeTitle,
        (context) => SizedBox(
              height: double.infinity,
              child: Column(
                children: [
                  Flexible(
                    child: Padding(
                      padding: const EdgeInsets.all(8),
                      child: Card(
                        child: ListTile(
                          title: Text(inputTitle),
                          subtitle: TextField(
                            maxLines: null,
                            controller: input,
                            expands: true,
                            decoration: InputDecoration(
                              isCollapsed: true,
                              border: InputBorder.none,
                              suffix: widget.lazy
                                  ? IconButton(
                                      onPressed: onInput,
                                      icon: const Icon(Icons.save),
                                      tooltip: 'Submit',
                                    )
                                  : null,
                            ),
                          ),
                        ),
                      ),
                    ),
                  ),
                  Flexible(
                    child: Padding(
                      padding: const EdgeInsets.all(8),
                      child: Card(
                        child: ListTile(
                          title: Text(outputTitle),
                          subtitle: TextField(
                            maxLines: null,
                            controller: output,
                            expands: true,
                            decoration: const InputDecoration(
                              isCollapsed: true,
                              border: InputBorder.none,
                            ),
                          ),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
      ),
      secondary: (
        widget.previewTitle,
        (context) => Container(
              color: Theme.of(context).colorScheme.surfaceVariant,
              child: Builder(builder: (context) {
                if (widget.previewSize == null) {
                  return widget.preview ?? widget.placeholder;
                }
                return Center(
                  child: Material(
                    elevation: 8,
                    child: SizedBox.fromSize(
                      size: widget.previewSize,
                      child: widget.preview ?? widget.placeholder,
                    ),
                  ),
                );
              }),
            )
      ),
    );
  }
}
```

Overview

This skill provides a reusable TwoPane widget and an InputOutputPreview component to build responsive Flutter apps that present code (or input/output text) alongside a live preview. It adapts layout between a side-by-side desktop view and a tabbed mobile view, includes a theme toggle and loading indicator, and supports lazy or live synchronization between text fields and preview updates.

How this skill works

TwoPane inspects the available layout constraints to render either a two-column Row (for wide screens) or a TabBar/TabBarView (for narrow screens). It manages theme toggling, app bar actions, and a linear progress indicator when loading. InputOutputPreview wraps TwoPane and provides two editable text areas (input and output) and a preview pane. Text controllers notify provided callbacks, with optional lazy submission, and the preview can be sized or fall back to a placeholder.

When to use it

  • Building editor-like UIs that show code or structured text next to a rendered preview.
  • Creating responsive examples or demos that must work on both mobile and desktop.
  • Tools where users edit input and see generated output or previews in real time.
  • Admin panels or internal apps that require quick side-by-side comparisons of source and result.
  • Prototyping UI components with a consistent, reusable layout and theme controls.

Best practices

  • Keep heavy preview widgets optional and use the previewSize parameter to constrain expensive renders.
  • Use lazy mode for expensive processing: set lazy=true and trigger updates with the Submit button to avoid frequent re-computation.
  • Provide lightweight placeholders for the preview to improve perceived performance on slow devices.
  • Pass concise, debounced update callbacks in the input/output tuples to avoid doing heavy work on every keystroke.
  • Leverage the TwoPane loading flag to surface background processing state to users.

Example use cases

  • Markdown editor: edit markdown in the code pane and render HTML preview in the preview pane with a fixed previewSize.
  • JSON transformer: input JSON, show transformed JSON in the output field, and render a visualized preview of the data structure.
  • Theme playground: change theme values in code and preview a live widget mockup with dark/light toggle in the app bar.
  • Component demo: show a component’s source on the left and an interactive rendered sample on the right, working across mobile and desktop.

FAQ

Can the preview run arbitrary widgets or web views?

Yes. Pass any Widget to the preview parameter; use previewSize to control its display and a placeholder while the widget mounts.

How do I avoid repeated expensive updates on each keystroke?

Enable lazy mode and handle updates in the provided callback when the user taps the Submit icon, or implement debouncing inside your update callbacks.

Will the layout automatically switch for tablets and small laptops?

Yes. TwoPane uses LayoutBuilder thresholds (width > 800 and height > 600) to choose side-by-side vs tabbed layout, which fits most tablets and laptops.