home / skills / rodydavis / 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-previewReview the files below or copy the command above to add this skill to your agents.
---
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,
),
),
);
}),
)
),
);
}
}
```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.
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.
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.