flutter_box_transform 0.2.0 copy "flutter_box_transform: ^0.2.0" to clipboard
flutter_box_transform: ^0.2.0 copied to clipboard

A Flutter implementation of box_transform package that provides easy 2D box transform operations with advanced resizing of rect in UI.

example/lib/main.dart

import 'dart:math';

import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_box_transform/flutter_box_transform.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart';

import 'resources/asset_icons.dart';
import 'resources/images.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return AdaptiveTheme(
      light: ThemeData(
        useMaterial3: true,
        brightness: Brightness.light,
        colorSchemeSeed: Colors.blue,
        inputDecorationTheme: InputDecorationTheme(
          isDense: true,
          contentPadding:
              const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(6),
          ),
        ),
      ),
      dark: ThemeData(
        useMaterial3: true,
        brightness: Brightness.dark,
        colorSchemeSeed: Colors.blue,
        inputDecorationTheme: InputDecorationTheme(
          isDense: true,
          contentPadding:
              const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(6),
          ),
        ),
      ),
      initial: AdaptiveThemeMode.system,
      builder: (theme, darkTheme) => MaterialApp(
        title: 'Box Transform demo',
        debugShowCheckedModeBanner: false,
        theme: theme,
        darkTheme: darkTheme,
        home: ChangeNotifierProvider(
          create: (_) => PlaygroundModel(),
          child: const Playground(),
        ),
      ),
    );
  }
}

class PlaygroundModel with ChangeNotifier {
  Rect? playgroundArea;

  final List<BoxData> boxes = [];

  int selectedBoxIndex = -1;

  BoxData? get selectedBox =>
      selectedBoxIndex != -1 ? boxes[selectedBoxIndex] : null;

  void reset(BuildContext context) {
    final Size size = MediaQuery.of(context).size;
    final double width = size.width - kSidePanelWidth - kBoxesPanelWidth;
    final double height = size.height;

    boxes.clear();
    boxes.add(
      BoxData(
        name: 'Box 1',
        imageAsset: Images.image1,
        rect: Rect.fromLTWH(
          (width - kInitialWidth) / 2,
          (height - kInitialHeight) / 2,
          kInitialWidth,
          kInitialHeight,
        ),
        clampingRect: Rect.fromLTWH(
          0,
          0,
          width,
          size.height,
        ),
        flip: Flip.none,
      ),
    );
    selectedBoxIndex = 0;

    notifyListeners();
  }

  void onRectChanged(UITransformResult result) {
    if (selectedBox == null) return;
    selectedBox!.rect = result.rect;
    if (result is UIResizeResult) {
      selectedBox!.flip = result.flip;
    }
    notifyListeners();
  }

  void onFlipChanged(Flip flip) {
    if (selectedBoxIndex == -1) return;
    selectedBox!.flip = flip;
    notifyListeners();
  }

  void onFlipChildChanged(bool enabled) {
    if (selectedBoxIndex == -1) return;
    selectedBox!.flipChild = enabled;
    notifyListeners();
  }

  void onFlipWhileResizingChanged(bool enabled) {
    if (selectedBoxIndex == -1) return;
    selectedBox!.flipRectWhileResizing = enabled;
    notifyListeners();
  }

  void setClampingRect(
    Rect rect, {
    bool notify = true,
    bool insidePlayground = false,
  }) {
    if (selectedBox == null) return;
    selectedBox!.clampingRect = rect;

    if (insidePlayground && playgroundArea != null) {
      selectedBox!.clampingRect = Rect.fromLTWH(
        selectedBox!.clampingRect.left.clamp(0.0, playgroundArea!.width),
        selectedBox!.clampingRect.top.clamp(0.0, playgroundArea!.height),
        selectedBox!.clampingRect.width.clamp(0.0, playgroundArea!.width),
        selectedBox!.clampingRect.height.clamp(0.0, playgroundArea!.height),
      );
    }

    if (notify) notifyListeners();
  }

  void addNewBox() {
    final double width = playgroundArea!.width;
    final double height = playgroundArea!.height;

    boxes.add(
      BoxData(
        name: 'Box ${boxes.length + 1}',
        imageAsset: Images.values[boxes.length % Images.values.length],
        rect: Rect.fromLTWH(
          playgroundArea!.center.dx - kInitialWidth / 2,
          playgroundArea!.center.dy - kInitialHeight / 2,
          kInitialWidth,
          kInitialHeight,
        ),
        clampingRect: Rect.fromLTWH(0, 0, width, height),
        flip: Flip.none,
      ),
    );

    notifyListeners();
  }

  void removeSelectedBox() {
    if (selectedBoxIndex == -1) return;
    boxes.removeAt(selectedBoxIndex);
    selectedBoxIndex = -1;
    notifyListeners();
  }

  void removeBox(int index) {
    if (index == -1) return;
    boxes.removeAt(index);
    if (index == selectedBoxIndex) {
      selectedBoxIndex = -1;
    }
    notifyListeners();
  }

  void setConstraints(BoxConstraints constraints, {bool notify = true}) {
    if (selectedBoxIndex == -1) return;
    selectedBox!.constraints = constraints;

    if (notify) notifyListeners();
  }

  void flipHorizontally() {
    if (selectedBoxIndex == -1) return;
    selectedBox!.flip = Flip.fromValue(
      selectedBox!.flip.horizontalValue * -1,
      selectedBox!.flip.verticalValue,
    );
    notifyListeners();
  }

  void flipVertically() {
    if (selectedBoxIndex == -1) return;
    selectedBox!.flip = Flip.fromValue(
      selectedBox!.flip.horizontalValue,
      selectedBox!.flip.verticalValue * -1,
    );
    notifyListeners();
  }

  void toggleClamping(bool enabled) {
    if (selectedBoxIndex == -1) return;
    selectedBox!.clampingEnabled = enabled;
    notifyListeners();
  }

  void toggleResizing(bool enabled) {
    if (selectedBoxIndex == -1) return;
    selectedBox!.resizable = enabled;
    notifyListeners();
  }

  void toggleMoving(bool enabled) {
    if (selectedBoxIndex == -1) return;
    selectedBox!.movable = enabled;
    notifyListeners();
  }

  void toggleConstraints(bool enabled) {
    if (selectedBoxIndex == -1) return;
    selectedBox!.constraintsEnabled = enabled;
    notifyListeners();
  }

  void toggleHideHandlesWhenNotResizable(bool enabled) {
    if (selectedBoxIndex == -1) return;
    selectedBox!.hideHandlesWhenNotResizable = enabled;
    notifyListeners();
  }

  void onClampingRectChanged({
    double? left,
    double? top,
    double? right,
    double? bottom,
  }) {
    if (selectedBox == null) return;
    selectedBox!.clampingRect = Rect.fromLTRB(
      left ?? selectedBox!.clampingRect.left,
      top ?? selectedBox!.clampingRect.top,
      right ?? selectedBox!.clampingRect.right,
      bottom ?? selectedBox!.clampingRect.bottom,
    );
    notifyListeners();
  }

  void onConstraintsChanged({
    double? minWidth,
    double? minHeight,
    double? maxWidth,
    double? maxHeight,
    bool forceMinWidth = false,
    bool forceMinHeight = false,
    bool forceMaxWidth = false,
    bool forceMaxHeight = false,
  }) {
    if (selectedBox == null) return;
    selectedBox!.constraints = BoxConstraints(
      minWidth: forceMinWidth
          ? minWidth ?? 0
          : minWidth ?? selectedBox!.constraints.minWidth,
      minHeight: forceMinHeight
          ? minHeight ?? 0
          : minHeight ?? selectedBox!.constraints.minHeight,
      maxWidth: forceMaxWidth
          ? maxWidth ?? double.infinity
          : maxWidth ?? selectedBox!.constraints.maxWidth,
      maxHeight: forceMaxHeight
          ? maxHeight ?? double.infinity
          : maxHeight ?? selectedBox!.constraints.maxHeight,
    );
    notifyListeners();
  }

  void onBoxSelected(int index) {
    selectedBoxIndex = index;
    notifyListeners();
  }

  void clearSelection() {
    selectedBoxIndex = -1;
    notifyListeners();
  }

  void onReorder(int oldIndex, int newIndex) {
    if (oldIndex < newIndex) {
      newIndex -= 1;
    }
    final selectedBox = this.selectedBox;
    final BoxData box = boxes.removeAt(oldIndex);
    boxes.insert(newIndex, box);
    if (selectedBox != null) {
      selectedBoxIndex =
          boxes.indexWhere((box) => box.name == selectedBox.name);
    }

    notifyListeners();
  }
}

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

  @override
  State<Playground> createState() => _PlaygroundState();
}

const double kSidePanelWidth = 280;
const double kBoxesPanelWidth = 250;
const double kInitialWidth = 400;
const double kInitialHeight = 300;
const double kStrokeWidth = 1.5;
const Color kGridColor = Color.fromARGB(126, 27, 181, 228);

class _PlaygroundState extends State<Playground> with WidgetsBindingObserver {
  Set<LogicalKeyboardKey> pressedKeys = {};

  late final FocusNode focusNode = FocusNode();

  late final PlaygroundModel model = context.read<PlaygroundModel>();

  @override
  void initState() {
    super.initState();

    // This is required to center the box based on screen size when the app
    // starts.
    SchedulerBinding.instance.addPostFrameCallback((_) {
      model.reset(context);
    });

    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (model.playgroundArea == null) resetPlayground();
  }

  @override
  void didChangeMetrics() {
    super.didChangeMetrics();
    resetPlayground(notify: true);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  // This is done to automatically resize clamping rect to the new screen size
  // when the app is resized but only when the clampingRect is already
  // set to the full screen. This is done to avoid the clamping rect to be
  // resized when the user has already resized it.
  void resetPlayground({bool notify = false}) {
    final PlaygroundModel model = context.read<PlaygroundModel>();
    final Size size = MediaQuery.of(context).size;
    model.playgroundArea = Rect.fromLTWH(
      0,
      0,
      max(size.width - kSidePanelWidth - kBoxesPanelWidth, 100),
      max(size.height,
          100), // safe size for when window is resized extremely small.
    );

    final Rect playgroundArea = model.playgroundArea!;

    for (final box in model.boxes) {
      if (box.clampingRect.width > playgroundArea.width ||
          box.clampingRect.height > playgroundArea.height) {
        if (model.selectedBox?.name == box.name) {
          model.setClampingRect(
            box.clampingRect,
            notify: notify,
            insidePlayground: true,
          );
        } else {
          box.clampingRect = Rect.fromLTWH(
            box.clampingRect.left.clamp(0.0, playgroundArea.width),
            box.clampingRect.top.clamp(0.0, playgroundArea.height),
            box.clampingRect.width.clamp(0.0, playgroundArea.width),
            box.clampingRect.height.clamp(0.0, playgroundArea.height),
          );
        }
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    final PlaygroundModel model = context.watch<PlaygroundModel>();

    return Scaffold(
      backgroundColor: Theme.of(context).scaffoldBackgroundColor,
      body: Row(
        children: [
          const BoxesPanel(),
          Expanded(
            child: CallbackShortcuts(
              bindings: {
                const SingleActivator(LogicalKeyboardKey.delete):
                    model.removeSelectedBox,
              },
              child: RawKeyboardListener(
                focusNode: focusNode,
                autofocus: true,
                onKey: (key) {
                  if (key is RawKeyDownEvent) {
                    pressedKeys.add(key.logicalKey);
                    setState(() {});
                  } else if (key is RawKeyUpEvent) {
                    pressedKeys.remove(key.logicalKey);
                    setState(() {});
                  }
                },
                child: GestureDetector(
                  onTap: () {
                    focusNode.requestFocus();
                    model.clearSelection();
                  },
                  behavior: HitTestBehavior.opaque,
                  child: Stack(
                    fit: StackFit.expand,
                    children: [
                      Positioned.fill(
                        child: GridPaper(
                          color: Theme.of(context).brightness == Brightness.dark
                              ? kGridColor.withOpacity(0.05)
                              : kGridColor.withOpacity(0.1),
                        ),
                      ),
                      if (model.selectedBox != null &&
                          model.selectedBox!.clampingEnabled &&
                          model.playgroundArea != null)
                        const ClampingRect(),
                      for (int index = 0; index < model.boxes.length; index++)
                        ImageBox(
                          key: ValueKey(model.boxes[index].name),
                          box: model.boxes[index],
                          selected: index == model.selectedBoxIndex,
                          onChanged: model.onRectChanged,
                          onSelected: () => model.onBoxSelected(index),
                        ),
                      Positioned(
                        left: 16,
                        bottom: 16,
                        child: KeyboardListenerIndicator(
                          pressedKeys: pressedKeys.toList(),
                          onClear: () => setState(() => pressedKeys = {}),
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),
          const ControlPanel(),
        ],
      ),
    );
  }
}

class ImageBox extends StatefulWidget {
  const ImageBox({
    super.key,
    required this.box,
    required this.onChanged,
    required this.onSelected,
    required this.selected,
  });

  final BoxData box;
  final bool selected;
  final VoidCallback onSelected;
  final Function(TransformResult<Rect, Offset, Size>)? onChanged;

  @override
  State<ImageBox> createState() => _ImageBoxState();
}

class _ImageBoxState extends State<ImageBox> {
  bool minWidthReached = false;
  bool minHeightReached = false;
  bool maxWidthReached = false;
  bool maxHeightReached = false;

  BoxData get box => widget.box;

  @override
  Widget build(BuildContext context) {
    // final PlaygroundModel model = context.watch<PlaygroundModel>();
    final Color handleColor = Theme.of(context).colorScheme.primary;
    return TransformableBox(
      key: ValueKey('image-box-${box.name}'),
      rect: box.rect,
      flip: box.flip,
      clampingRect: box.clampingEnabled ? box.clampingRect : null,
      constraints: box.constraintsEnabled ? box.constraints : null,
      onChanged: widget.onChanged,
      resizable: widget.selected && box.resizable,
      hideHandlesWhenNotResizable:
          !widget.selected || box.hideHandlesWhenNotResizable,
      movable: widget.selected && box.movable,
      allowContentFlipping: box.flipChild,
      flipWhileResizing: box.flipRectWhileResizing,
      onTerminalSizeReached: (
        bool reachedMinWidth,
        bool reachedMaxWidth,
        bool reachedMinHeight,
        bool reachedMaxHeight,
      ) {
        if (minWidthReached == reachedMinWidth &&
            minHeightReached == reachedMinHeight &&
            maxWidthReached == reachedMaxWidth &&
            maxHeightReached == reachedMaxHeight) return;

        setState(() {
          minWidthReached = reachedMinWidth;
          minHeightReached = reachedMinHeight;
          maxWidthReached = reachedMaxWidth;
          maxHeightReached = reachedMaxHeight;
        });
      },
      contentBuilder: (context, rect, flip) => GestureDetector(
        onTap: () {
          if (widget.selected) return;
          widget.onSelected();
        },
        onPanStart: (_) {
          if (widget.selected) return;
          widget.onSelected();
        },
        // onTapDown: (_) {
        //   if(widget.selected) return;
        //   widget.onSelected();
        // },
        child: Container(
          key: ValueKey('image-box-${box.name}-content'),
          width: rect.width,
          height: rect.height,
          decoration: BoxDecoration(
            color: Colors.white,
            image: DecorationImage(
              image: AssetImage(box.imageAsset),
              fit: BoxFit.fill,
            ),
          ),
          foregroundDecoration: BoxDecoration(
            border: widget.selected
                ? Border.symmetric(
                    horizontal: BorderSide(
                      color: minHeightReached
                          ? Colors.orange
                          : maxHeightReached
                              ? Colors.red
                              : handleColor,
                      width: 2,
                      strokeAlign: BorderSide.strokeAlignCenter,
                    ),
                    vertical: BorderSide(
                      color: minWidthReached
                          ? Colors.orange
                          : maxWidthReached
                              ? Colors.red
                              : handleColor,
                      width: 2,
                      strokeAlign: BorderSide.strokeAlignCenter,
                    ),
                  )
                : null,
          ),
        ),
      ),
    );
  }
}

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

  @override
  State<ClampingRect> createState() => _ClampingRectState();
}

class _ClampingRectState extends State<ClampingRect> {
  bool minWidthReached = false;
  bool minHeightReached = false;
  bool maxWidthReached = false;
  bool maxHeightReached = false;

  @override
  Widget build(BuildContext context) {
    final PlaygroundModel model = context.watch<PlaygroundModel>();
    const Color mainColor = Colors.green;
    final Color horizontalEdgeColor, verticalEdgeColor;
    final bool anyTerminalSize = minWidthReached ||
        minHeightReached ||
        maxWidthReached ||
        maxHeightReached;
    if (minHeightReached) {
      horizontalEdgeColor = Colors.orange;
    } else if (maxHeightReached) {
      horizontalEdgeColor = Colors.red;
    } else {
      horizontalEdgeColor = mainColor;
    }
    if (minWidthReached) {
      verticalEdgeColor = Colors.orange;
    } else if (maxWidthReached) {
      verticalEdgeColor = Colors.red;
    } else {
      verticalEdgeColor = mainColor;
    }

    final String label;
    if (minWidthReached && minHeightReached) {
      label = 'Min size reached';
    } else if (maxWidthReached && maxHeightReached) {
      label = 'Max size reached';
    } else if (minWidthReached) {
      label = 'Min width reached';
    } else if (minHeightReached) {
      label = 'Min height reached';
    } else if (maxWidthReached) {
      label = 'Max width reached';
    } else if (maxHeightReached) {
      label = 'Max height reached';
    } else {
      label = 'Clamping Box';
    }

    final BoxData box = model.selectedBox!;

    return TransformableBox(
      key: ValueKey('clamping-box-${box.name}'),
      rect: box.clampingRect,
      flip: Flip.none,
      clampingRect: model.playgroundArea!,
      constraints: BoxConstraints(
        minWidth: box.rect.width,
        minHeight: box.rect.height,
      ),
      onChanged: (result) => model.setClampingRect(result.rect),
      onTerminalSizeReached: (
        bool reachedMinWidth,
        bool reachedMaxWidth,
        bool reachedMinHeight,
        bool reachedMaxHeight,
      ) {
        if (minWidthReached == reachedMinWidth &&
            minHeightReached == reachedMinHeight &&
            maxWidthReached == reachedMaxWidth &&
            maxHeightReached == reachedMaxHeight) return;

        setState(() {
          minWidthReached = reachedMinWidth;
          minHeightReached = reachedMinHeight;
          maxWidthReached = reachedMaxWidth;
          maxHeightReached = reachedMaxHeight;
        });
      },
      handleAlign: HandleAlign.inside,
      cornerHandleBuilder: (context, handle) => AngularHandle(
        handle: handle,
        color: mainColor,
        hasShadow: false,
        handleAlign: HandleAlign.inside,
      ),
      sideHandleBuilder: (context, handle) => AngularHandle(
        handle: handle,
        color: mainColor,
        hasShadow: false,
        handleAlign: HandleAlign.inside,
      ),
      contentBuilder: (context, _, flip) => Container(
        width: box.clampingRect.width,
        height: box.clampingRect.height,
        alignment: Alignment.bottomRight,
        decoration: BoxDecoration(
          border: Border.symmetric(
            horizontal: BorderSide(
              color: horizontalEdgeColor,
              width: 1.5,
            ),
            vertical: BorderSide(
              color: verticalEdgeColor,
              width: 1.5,
            ),
          ),
        ),
        child: DecoratedBox(
          decoration: BoxDecoration(
            color:
                (anyTerminalSize ? Colors.orange : mainColor).withOpacity(0.1),
            borderRadius: const BorderRadius.only(
              topLeft: Radius.circular(6),
            ),
          ),
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(
              label,
              style: TextStyle(
                color: anyTerminalSize ? Colors.orange : mainColor,
                fontSize: 12,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final PlaygroundModel model = context.watch<PlaygroundModel>();

    final BoxData? box = model.selectedBox;

    return Card(
      margin: const EdgeInsets.only(left: 0),
      shape: const RoundedRectangleBorder(),
      child: SizedBox(
        width: kSidePanelWidth,
        child: ListView(
          children: [
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
              child: Row(
                children: [
                  Expanded(
                    child: FilledButton.tonalIcon(
                      onPressed: () => model.reset(context),
                      icon: const Icon(Icons.refresh),
                      label: const Text('Reset'),
                    ),
                  ),
                  const SizedBox(width: 16),
                  IconButton(
                    tooltip: 'Open on GitHub',
                    onPressed: () => launchUrlString(
                        'https://github.com/birjuvachhani/rect_resizer'),
                    icon: const Icon(Icons.open_in_new_rounded),
                  ),
                  const SizedBox(width: 8),
                  IconButton(
                    onPressed: () =>
                        AdaptiveTheme.of(context).toggleThemeMode(),
                    tooltip: 'Toggle Theme',
                    icon: ValueListenableBuilder<AdaptiveThemeMode>(
                      valueListenable:
                          AdaptiveTheme.of(context).modeChangeNotifier,
                      builder: (context, mode, child) => Icon(
                        mode == AdaptiveThemeMode.system
                            ? Icons.brightness_auto_outlined
                            : mode == AdaptiveThemeMode.dark
                                ? Icons.brightness_2_outlined
                                : Icons.brightness_5_rounded,
                      ),
                    ),
                  ),
                ],
              ),
            ),
            const Divider(height: 1),
            if (box != null) ...[
              const PositionControls(),
              const Divider(height: 1),
              Container(
                height: 44,
                padding: const EdgeInsets.fromLTRB(16, 0, 6, 0),
                alignment: Alignment.centerLeft,
                child: Row(
                  children: [
                    Expanded(
                      child: Text(
                        'Resizable',
                        style: Theme.of(context).textTheme.titleSmall!.copyWith(
                              color: Theme.of(context).colorScheme.secondary,
                            ),
                      ),
                    ),
                    SizedBox(
                      height: 20,
                      child: Transform.scale(
                        scale: 0.7,
                        child: Switch(
                          value: box.resizable,
                          onChanged: (value) => model.toggleResizing(value),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              const Divider(height: 1),
              Container(
                height: 44,
                padding: const EdgeInsets.fromLTRB(16, 0, 6, 0),
                alignment: Alignment.centerLeft,
                child: Row(
                  children: [
                    Expanded(
                      child: Text(
                        'Movable',
                        style: Theme.of(context).textTheme.titleSmall!.copyWith(
                              color: Theme.of(context).colorScheme.secondary,
                            ),
                      ),
                    ),
                    SizedBox(
                      height: 20,
                      child: Transform.scale(
                        scale: 0.7,
                        child: Switch(
                          value: box.movable,
                          onChanged: (value) => model.toggleMoving(value),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              const Divider(height: 1),
              Container(
                height: 44,
                padding: const EdgeInsets.fromLTRB(16, 0, 6, 0),
                alignment: Alignment.centerLeft,
                child: Row(
                  children: [
                    Expanded(
                      child: Text(
                        'Hide handles if not resizable',
                        style: Theme.of(context).textTheme.titleSmall!.copyWith(
                              color: Theme.of(context).colorScheme.secondary,
                            ),
                      ),
                    ),
                    SizedBox(
                      height: 20,
                      child: Transform.scale(
                        scale: 0.7,
                        child: Switch(
                          value: box.hideHandlesWhenNotResizable,
                          onChanged: (value) =>
                              model.toggleHideHandlesWhenNotResizable(value),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              const Divider(height: 1),
              const FlipControls(),
              const Divider(height: 1),
              const ClampingControls(),
              const Divider(height: 1),
              const ConstraintsControls(),
              const Divider(height: 1),
            ] else ...[
              const SizedBox(height: 44),
              Text(
                'Select a box to see controls',
                textAlign: TextAlign.center,
                style: Theme.of(context).textTheme.titleSmall!.copyWith(
                      color: Theme.of(context).colorScheme.secondary,
                    ),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final PlaygroundModel model = context.watch<PlaygroundModel>();

    return Card(
      margin: const EdgeInsets.only(left: 0),
      shape: const RoundedRectangleBorder(),
      child: SizedBox(
        width: kBoxesPanelWidth,
        height: MediaQuery.of(context).size.height,
        child: SingleChildScrollView(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: [
              Container(
                padding: const EdgeInsets.fromLTRB(16, 0, 4, 0),
                child: Row(
                  children: [
                    const SectionHeader(
                      'Boxes',
                      padding: EdgeInsets.zero,
                    ),
                    Text(
                      ' • ${model.boxes.length}',
                      style: TextStyle(
                        color: Theme.of(context).colorScheme.secondary,
                        letterSpacing: 1,
                        fontSize: 12,
                      ),
                    ),
                    const Spacer(),
                    IconButton(
                      onPressed: model.selectedBox != null
                          ? model.removeSelectedBox
                          : null,
                      tooltip: 'Delete selected box',
                      iconSize: 18,
                      splashRadius: 18,
                      color: Theme.of(context).colorScheme.error,
                      icon: const Icon(Icons.delete_outline_outlined),
                    ),
                    IconButton(
                      onPressed: model.addNewBox,
                      tooltip: 'Add new box',
                      iconSize: 18,
                      splashRadius: 18,
                      icon: const Icon(Icons.add),
                    ),
                  ],
                ),
              ),
              const Divider(height: 1),
              if (model.boxes.isEmpty) ...[
                const SizedBox(height: 44),
                Text(
                  'Add a box to see controls',
                  textAlign: TextAlign.center,
                  style: Theme.of(context).textTheme.titleSmall!.copyWith(
                        color: Theme.of(context).colorScheme.secondary,
                      ),
                ),
              ],
              if (model.boxes.isNotEmpty)
                ReorderableListView.builder(
                  itemCount: model.boxes.length,
                  onReorder: model.onReorder,
                  reverse: true,
                  shrinkWrap: true,
                  buildDefaultDragHandles: false,
                  primary: false,
                  itemBuilder: (context, index) {
                    final box = model.boxes[index];
                    return ReorderableDragStartListener(
                      index: index,
                      key: ValueKey(box.name),
                      child: Container(
                        color: box.name == model.selectedBox?.name
                            ? Theme.of(context)
                                .colorScheme
                                .primary
                                .withOpacity(0.2)
                            : null,
                        child: ListTile(
                          title: Text(box.name),
                          selected: box.name == model.selectedBox?.name,
                          onTap: () => model.onBoxSelected(index),
                          leading: const Icon(
                            Icons.border_style_outlined,
                            size: 18,
                          ),
                          minLeadingWidth: 20,
                          dense: true,
                          // selectedTileColor:
                          //     Theme.of(context).colorScheme.primary.withOpacity(0.2),
                        ),
                      ),
                    );
                  },
                ),
            ],
          ),
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final PlaygroundModel model = context.watch<PlaygroundModel>();

    final BoxData box = model.selectedBox!;

    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      mainAxisSize: MainAxisSize.min,
      children: [
        const SectionHeader('POSITION'),
        Padding(
          padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Row(
                children: const [
                  Expanded(child: Label('X')),
                  SizedBox(width: 16),
                  Expanded(child: Label('Y')),
                ],
              ),
              const SizedBox(height: 8),
              Row(
                children: [
                  Expanded(
                    child: ValueText(box.rect.left.toStringAsFixed(0)),
                  ),
                  const SizedBox(width: 16),
                  Expanded(
                    child: ValueText(box.rect.top.toStringAsFixed(0)),
                  ),
                ],
              ),
              const SizedBox(height: 16),
              Row(
                children: const [
                  Expanded(child: Label('WIDTH')),
                  SizedBox(width: 16),
                  Expanded(child: Label('HEIGHT')),
                ],
              ),
              const SizedBox(height: 8),
              Row(
                children: [
                  Expanded(
                    child: ValueText(box.rect.width.toStringAsFixed(0)),
                  ),
                  const SizedBox(width: 16),
                  Expanded(
                    child: ValueText(box.rect.height.toStringAsFixed(0)),
                  ),
                ],
              ),
              const SizedBox(height: 16),
              Row(
                children: const [
                  Expanded(child: Label('ASPECT RATIO')),
                ],
              ),
              const SizedBox(height: 8),
              Row(
                children: [
                  Expanded(
                    child: ValueText(
                      (box.rect.width / box.rect.height).toStringAsFixed(2),
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      ],
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final PlaygroundModel model = context.watch<PlaygroundModel>();

    final BoxData box = model.selectedBox!;

    return AnimatedSize(
      duration: const Duration(milliseconds: 200),
      curve: Curves.easeInOut,
      alignment: Alignment.topCenter,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            // color: Theme.of(context).colorScheme.secondary.withOpacity(0.1),
            // height: 44,
            padding: const EdgeInsets.fromLTRB(16, 16, 6, 16),
            alignment: Alignment.centerLeft,
            child: Row(
              children: [
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Text(
                        'Flip rect while resizing',
                        style: Theme.of(context).textTheme.titleSmall!.copyWith(
                              color: Theme.of(context).colorScheme.secondary,
                              fontWeight: FontWeight.bold,
                            ),
                      ),
                      const SizedBox(height: 4),
                      FractionallySizedBox(
                        widthFactor: 0.9,
                        child: Text(
                          'Allows to flip the rect while resizing. The actual contents of the rect won\'t be flipped.',
                          style: Theme.of(context)
                              .textTheme
                              .bodySmall!
                              .copyWith(
                                color: Theme.of(context).colorScheme.secondary,
                              ),
                        ),
                      ),
                    ],
                  ),
                ),
                SizedBox(
                  height: 20,
                  child: Transform.scale(
                    scale: 0.7,
                    child: Switch(
                      value: box.flipRectWhileResizing,
                      onChanged: (value) =>
                          model.onFlipWhileResizingChanged(value),
                    ),
                  ),
                ),
              ],
            ),
          ),
          const Divider(height: 1),
          Container(
            // color: Theme.of(context).colorScheme.secondary.withOpacity(0.1),
            // height: 44,
            padding: const EdgeInsets.fromLTRB(16, 16, 6, 16),
            alignment: Alignment.centerLeft,
            child: Row(
              children: [
                Expanded(
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Flip Content',
                        style: Theme.of(context).textTheme.titleSmall!.copyWith(
                              color: Theme.of(context).colorScheme.secondary,
                            ),
                      ),
                      const SizedBox(height: 4),
                      FractionallySizedBox(
                        widthFactor: 0.9,
                        child: Text(
                          'Flip the contents of the rect when it is flipped.',
                          style: Theme.of(context)
                              .textTheme
                              .bodySmall!
                              .copyWith(
                                color: Theme.of(context).colorScheme.secondary,
                              ),
                        ),
                      ),
                    ],
                  ),
                ),
                SizedBox(
                  height: 20,
                  child: Transform.scale(
                    scale: 0.7,
                    child: Switch(
                      value: box.flipChild,
                      onChanged: (value) => model.onFlipChildChanged(value),
                    ),
                  ),
                ),
              ],
            ),
          ),
          if (box.flipChild)
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
              child: Row(
                children: [
                  ToggleButtons(
                    onPressed: (index) {
                      if (index == 0) {
                        model.flipHorizontally();
                      } else {
                        model.flipVertically();
                      }
                    },
                    isSelected: [
                      box.flip.isHorizontal,
                      box.flip.isVertical,
                    ],
                    selectedColor: Theme.of(context).colorScheme.primary,
                    constraints: const BoxConstraints.tightFor(height: 32),
                    children: [
                      Tooltip(
                        message: 'Flip Horizontally',
                        waitDuration: const Duration(milliseconds: 500),
                        child: Padding(
                          padding: const EdgeInsets.symmetric(horizontal: 8),
                          child: Row(
                            children: const [
                              ImageIcon(
                                AssetImage(AssetIcons.icFlip),
                                size: 20,
                              ),
                              SizedBox(width: 8),
                              Text('Horizontal'),
                            ],
                          ),
                        ),
                      ),
                      Tooltip(
                        message: 'Flip Vertically',
                        waitDuration: const Duration(milliseconds: 500),
                        child: Padding(
                          padding: const EdgeInsets.symmetric(horizontal: 8),
                          child: Row(
                            children: const [
                              RotatedBox(
                                quarterTurns: 1,
                                child: ImageIcon(
                                  AssetImage(AssetIcons.icFlip),
                                  size: 20,
                                ),
                              ),
                              SizedBox(width: 8),
                              Text('Vertical'),
                            ],
                          ),
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
        ],
      ),
    );
  }
}

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

  @override
  State<ClampingControls> createState() => _ClampingControlsState();
}

class _ClampingControlsState extends State<ClampingControls> {
  late final PlaygroundModel model = context.read<PlaygroundModel>();

  late final TextEditingController leftController;
  late final TextEditingController topController;
  late final TextEditingController bottomController;
  late final TextEditingController rightController;

  double get left => double.tryParse(leftController.text) ?? 0;

  double get top => double.tryParse(topController.text) ?? 0;

  double get bottom => double.tryParse(bottomController.text) ?? 0;

  double get right => double.tryParse(rightController.text) ?? 0;

  BoxData get box => model.selectedBox!;

  @override
  void initState() {
    super.initState();
    leftController =
        TextEditingController(text: box.clampingRect.left.toStringAsFixed(0));
    topController =
        TextEditingController(text: box.clampingRect.top.toStringAsFixed(0));
    bottomController =
        TextEditingController(text: box.clampingRect.bottom.toStringAsFixed(0));
    rightController =
        TextEditingController(text: box.clampingRect.right.toStringAsFixed(0));

    model.addListener(onModelChanged);
  }

  void onModelChanged() {
    if (model.selectedBox == null) return;
    if (box.clampingRect.left != left) {
      leftController.text = box.clampingRect.left.toStringAsFixed(0);
    }
    if (box.clampingRect.top != top) {
      topController.text = box.clampingRect.top.toStringAsFixed(0);
    }
    if (box.clampingRect.bottom != bottom) {
      bottomController.text = box.clampingRect.bottom.toStringAsFixed(0);
    }
    if (box.clampingRect.right != right) {
      rightController.text = box.clampingRect.right.toStringAsFixed(0);
    }
  }

  @override
  Widget build(BuildContext context) {
    final PlaygroundModel model = context.watch<PlaygroundModel>();

    final BoxData box = model.selectedBox!;

    return FocusScope(
      child: Builder(
        builder: (context) {
          return GestureDetector(
            onTap: () {
              FocusScope.of(context).unfocus();
              model.onClampingRectChanged(
                left: left,
                top: top,
                bottom: bottom,
                right: right,
              );
            },
            child: AnimatedSize(
              duration: const Duration(milliseconds: 200),
              curve: Curves.easeInOut,
              alignment: Alignment.topCenter,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                mainAxisSize: MainAxisSize.min,
                children: [
                  Container(
                    height: 44,
                    padding: const EdgeInsets.fromLTRB(16, 0, 6, 0),
                    alignment: Alignment.centerLeft,
                    child: Row(
                      children: [
                        Expanded(
                          child: Text(
                            'CLAMPING',
                            style: Theme.of(context)
                                .textTheme
                                .titleSmall!
                                .copyWith(
                                  color:
                                      Theme.of(context).colorScheme.secondary,
                                ),
                          ),
                        ),
                        SizedBox(
                          height: 20,
                          child: Transform.scale(
                            scale: 0.7,
                            child: Switch(
                              value: box.clampingEnabled,
                              onChanged: (value) => model.toggleClamping(value),
                            ),
                          ),
                        ),
                      ],
                    ),
                  ),
                  if (box.clampingEnabled)
                    Padding(
                      padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Row(
                            children: const [
                              Expanded(child: Label('LEFT')),
                              SizedBox(width: 16),
                              Expanded(child: Label('TOP')),
                            ],
                          ),
                          const SizedBox(height: 8),
                          Row(
                            children: [
                              Expanded(
                                child: TextField(
                                  enabled: box.clampingEnabled,
                                  controller: leftController,
                                  onSubmitted: (value) {
                                    model.onClampingRectChanged(left: left);
                                  },
                                  decoration: const InputDecoration(
                                    suffixText: 'px',
                                  ),
                                  inputFormatters: [
                                    FilteringTextInputFormatter.allow(
                                      RegExp(r'[0-9]*'),
                                    ),
                                  ],
                                ),
                              ),
                              const SizedBox(width: 16),
                              Expanded(
                                child: TextField(
                                  enabled: box.clampingEnabled,
                                  controller: topController,
                                  onSubmitted: (value) {
                                    model.onClampingRectChanged(top: top);
                                  },
                                  decoration: const InputDecoration(
                                    suffixText: 'px',
                                  ),
                                  inputFormatters: [
                                    FilteringTextInputFormatter.allow(
                                      RegExp(r'[0-9]*'),
                                    ),
                                  ],
                                ),
                              ),
                            ],
                          ),
                          const SizedBox(height: 16),
                          Row(
                            children: const [
                              Expanded(child: Label('RIGHT')),
                              SizedBox(width: 16),
                              Expanded(child: Label('BOTTOM')),
                            ],
                          ),
                          const SizedBox(height: 8),
                          Row(
                            children: [
                              Expanded(
                                child: TextFormField(
                                  enabled: box.clampingEnabled,
                                  controller: rightController,
                                  onFieldSubmitted: (value) {
                                    model.onClampingRectChanged(right: right);
                                  },
                                  decoration: const InputDecoration(
                                    suffixText: 'px',
                                  ),
                                  inputFormatters: [
                                    FilteringTextInputFormatter.allow(
                                      RegExp(r'[0-9]*'),
                                    ),
                                  ],
                                ),
                              ),
                              const SizedBox(width: 16),
                              Expanded(
                                child: TextField(
                                  enabled: box.clampingEnabled,
                                  controller: bottomController,
                                  onSubmitted: (value) {
                                    model.onClampingRectChanged(bottom: bottom);
                                  },
                                  decoration: const InputDecoration(
                                    suffixText: 'px',
                                  ),
                                  inputFormatters: [
                                    FilteringTextInputFormatter.allow(
                                      RegExp(r'[0-9]*'),
                                    ),
                                  ],
                                ),
                              ),
                            ],
                          ),
                          const SizedBox(height: 16),
                          FilledButton.tonalIcon(
                            onPressed: () {
                              model.setClampingRect(model.playgroundArea!);
                            },
                            icon: const Icon(Icons.fullscreen_rounded),
                            label: const Text('Full screen'),
                          ),
                        ],
                      ),
                    ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }

  @override
  void dispose() {
    model.removeListener(onModelChanged);
    super.dispose();
  }
}

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

  @override
  State<ConstraintsControls> createState() => _ConstraintsControlsState();
}

class _ConstraintsControlsState extends State<ConstraintsControls> {
  late final PlaygroundModel model = context.read<PlaygroundModel>();

  late final TextEditingController minWidthController;
  late final TextEditingController minHeightController;
  late final TextEditingController maxWidthController;
  late final TextEditingController maxHeightController;

  double? get minWidth => double.tryParse(minWidthController.text) ?? 0;

  double? get minHeight => double.tryParse(minHeightController.text) ?? 0;

  double? get maxWidth => double.tryParse(maxWidthController.text);

  double? get maxHeight => double.tryParse(maxHeightController.text);

  BoxData get box => model.selectedBox!;

  @override
  void initState() {
    super.initState();
    minWidthController =
        TextEditingController(text: formatted(box.constraints.minWidth));
    minHeightController =
        TextEditingController(text: formatted(box.constraints.minHeight));
    maxHeightController =
        TextEditingController(text: formatted(box.constraints.maxHeight));
    maxWidthController =
        TextEditingController(text: formatted(box.constraints.maxWidth));

    model.addListener(onModelChanged);
  }

  void onModelChanged() {
    if (model.selectedBox == null) return;
    if (box.constraints.minWidth != minWidth) {
      minWidthController.text = formatted(box.constraints.minWidth);
    }
    if (box.constraints.minHeight != minHeight) {
      minHeightController.text = formatted(box.constraints.minHeight);
    }
    if (box.constraints.maxHeight != maxHeight) {
      maxHeightController.text = formatted(box.constraints.maxHeight);
    }
    if (box.constraints.maxWidth != maxWidth) {
      maxWidthController.text = formatted(box.constraints.maxWidth);
    }
  }

  String formatted(double? value) {
    if (value == null || value == 0) return '';
    if (value.isInfinite) return '';
    return value.toStringAsFixed(0);
  }

  @override
  Widget build(BuildContext context) {
    final PlaygroundModel model = context.watch<PlaygroundModel>();
    return FocusScope(
      child: Builder(
        builder: (context) {
          return GestureDetector(
            onTap: () {
              FocusScope.of(context).unfocus();
              model.onConstraintsChanged(
                minWidth: minWidth,
                minHeight: minHeight,
                maxHeight: maxHeight,
                maxWidth: maxWidth,
                forceMinWidth: true,
                forceMinHeight: true,
                forceMaxWidth: true,
                forceMaxHeight: true,
              );
            },
            child: AnimatedSize(
              duration: const Duration(milliseconds: 200),
              curve: Curves.easeInOut,
              alignment: Alignment.topCenter,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                mainAxisSize: MainAxisSize.min,
                children: [
                  Container(
                    height: 44,
                    padding: const EdgeInsets.fromLTRB(16, 0, 6, 0),
                    alignment: Alignment.centerLeft,
                    child: Row(
                      children: [
                        Expanded(
                          child: Text(
                            'CONSTRAINTS',
                            style: Theme.of(context)
                                .textTheme
                                .titleSmall!
                                .copyWith(
                                  color:
                                      Theme.of(context).colorScheme.secondary,
                                ),
                          ),
                        ),
                        SizedBox(
                          height: 20,
                          child: Transform.scale(
                            scale: 0.7,
                            child: Switch(
                              value: box.constraintsEnabled,
                              onChanged: (value) =>
                                  model.toggleConstraints(value),
                            ),
                          ),
                        ),
                      ],
                    ),
                  ),
                  if (box.constraintsEnabled)
                    Padding(
                      padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Row(
                            children: const [
                              Expanded(child: Label('Min W')),
                              SizedBox(width: 16),
                              Expanded(child: Label('Min H')),
                            ],
                          ),
                          const SizedBox(height: 8),
                          Row(
                            children: [
                              Expanded(
                                child: TextField(
                                  enabled: box.constraintsEnabled,
                                  controller: minWidthController,
                                  onSubmitted: (value) {
                                    model.onConstraintsChanged(
                                      minWidth: minWidth,
                                      forceMinWidth: true,
                                    );
                                  },
                                  decoration: const InputDecoration(
                                    suffixText: 'px',
                                    hintText: '0',
                                  ),
                                  inputFormatters: [
                                    FilteringTextInputFormatter.allow(
                                      RegExp(r'[0-9]*'),
                                    ),
                                  ],
                                ),
                              ),
                              const SizedBox(width: 16),
                              Expanded(
                                child: TextField(
                                  enabled: box.constraintsEnabled,
                                  controller: minHeightController,
                                  onSubmitted: (value) {
                                    model.onConstraintsChanged(
                                      minHeight: minHeight,
                                      forceMinHeight: true,
                                    );
                                  },
                                  decoration: const InputDecoration(
                                    suffixText: 'px',
                                    hintText: '0',
                                  ),
                                  inputFormatters: [
                                    FilteringTextInputFormatter.allow(
                                      RegExp(r'[0-9]*'),
                                    ),
                                  ],
                                ),
                              ),
                            ],
                          ),
                          const SizedBox(height: 16),
                          Row(
                            children: const [
                              Expanded(child: Label('Max W')),
                              SizedBox(width: 16),
                              Expanded(child: Label('Max H')),
                            ],
                          ),
                          const SizedBox(height: 8),
                          Row(
                            children: [
                              Expanded(
                                child: TextFormField(
                                  enabled: box.constraintsEnabled,
                                  controller: maxWidthController,
                                  onFieldSubmitted: (value) {
                                    model.onConstraintsChanged(
                                      maxWidth: maxWidth,
                                      forceMaxWidth: true,
                                    );
                                  },
                                  decoration: const InputDecoration(
                                    suffixText: 'px',
                                    hintText: '∞',
                                  ),
                                  inputFormatters: [
                                    FilteringTextInputFormatter.allow(
                                      RegExp(r'[0-9]*'),
                                    ),
                                  ],
                                ),
                              ),
                              const SizedBox(width: 16),
                              Expanded(
                                child: TextField(
                                  enabled: box.constraintsEnabled,
                                  controller: maxHeightController,
                                  onSubmitted: (value) {
                                    model.onConstraintsChanged(
                                      maxHeight: maxHeight,
                                      forceMaxHeight: true,
                                    );
                                  },
                                  decoration: const InputDecoration(
                                    suffixText: 'px',
                                    hintText: '∞',
                                  ),
                                  inputFormatters: [
                                    FilteringTextInputFormatter.allow(
                                      RegExp(r'[0-9]*'),
                                    ),
                                  ],
                                ),
                              ),
                            ],
                          ),
                          const SizedBox(height: 16),
                          FilledButton.tonalIcon(
                            onPressed: () {
                              model.setConstraints(const BoxConstraints(
                                minWidth: double.infinity,
                                minHeight: double.infinity,
                              ));
                            },
                            icon: const Icon(Icons.refresh),
                            label: const Text('Reset'),
                          ),
                        ],
                      ),
                    ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }

  @override
  void dispose() {
    model.removeListener(onModelChanged);
    super.dispose();
  }
}

class KeyboardListenerIndicator extends StatelessWidget {
  final List<LogicalKeyboardKey> pressedKeys;
  final VoidCallback onClear;

  const KeyboardListenerIndicator({
    super.key,
    required this.pressedKeys,
    required this.onClear,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        for (final key in pressedKeys)
          Container(
            margin: EdgeInsets.only(right: pressedKeys.last != key ? 12 : 0),
            padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
            decoration: BoxDecoration(
              color: Theme.of(context).scaffoldBackgroundColor,
              borderRadius: BorderRadius.circular(6),
              border: Border.all(
                color: Theme.of(context).colorScheme.primary,
                width: 1.5,
              ),
              boxShadow: [
                BoxShadow(
                  color:
                      Theme.of(context).colorScheme.onPrimary.withOpacity(0.2),
                  blurRadius: 1,
                  offset: const Offset(1, 3),
                ),
              ],
            ),
            child: Text(
              prettifyKey(key),
              style: TextStyle(
                color: Theme.of(context).colorScheme.primary,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        if (pressedKeys.isNotEmpty)
          IconButton(
            onPressed: onClear,
            splashRadius: 16,
            iconSize: 18,
            icon: Icon(
              Icons.clear,
              color: Colors.grey.shade400,
            ),
          ),
      ],
    );
  }

  String prettifyKey(LogicalKeyboardKey key) {
    final keyLabel = key.keyLabel;
    if (key == LogicalKeyboardKey.arrowLeft) return '←';
    if (key == LogicalKeyboardKey.arrowRight) return '→';
    if (key == LogicalKeyboardKey.arrowUp) return '↑';
    if (key == LogicalKeyboardKey.arrowDown) return '↓';
    if (key == LogicalKeyboardKey.escape) return 'ESC';
    if (key == LogicalKeyboardKey.shiftLeft) return 'SHIFT';
    if (key == LogicalKeyboardKey.altLeft) return 'ALT';
    if (key == LogicalKeyboardKey.controlLeft) return 'CTRL';
    if (key == LogicalKeyboardKey.metaLeft) return 'CMD';
    return keyLabel;
  }
}

class Label extends StatelessWidget {
  final String label;

  const Label(this.label, {super.key});

  @override
  Widget build(BuildContext context) {
    return Text(
      label,
      style: Theme.of(context).textTheme.labelMedium!.copyWith(
            color: Theme.of(context).colorScheme.primary,
          ),
    );
  }
}

class ValueText extends StatelessWidget {
  final String value;

  const ValueText(this.value, {super.key});

  @override
  Widget build(BuildContext context) {
    return Text(
      value,
      style: Theme.of(context).textTheme.titleMedium,
    );
  }
}

class SectionHeader extends StatelessWidget {
  final String title;
  final EdgeInsets? padding;
  final double? height;

  const SectionHeader(
    this.title, {
    super.key,
    this.padding,
    this.height,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      height: height ?? 44,
      padding: padding ?? const EdgeInsets.symmetric(horizontal: 16),
      alignment: Alignment.centerLeft,
      child: Text(
        title,
        style: Theme.of(context).textTheme.titleSmall!.copyWith(
              color: Theme.of(context).colorScheme.secondary,
            ),
      ),
    );
  }
}

class BoxData {
  final String name;
  Rect rect = Rect.zero;
  Flip flip = Flip.none;
  Rect rect2 = Rect.zero;
  Flip flip2 = Flip.none;
  Rect clampingRect = Rect.largest;
  BoxConstraints constraints;

  bool flipRectWhileResizing = true;
  bool flipChild = true;
  bool clampingEnabled = false;
  bool constraintsEnabled = false;
  bool resizable = true;
  bool movable = true;
  bool hideHandlesWhenNotResizable = true;

  final String imageAsset;

  BoxData({
    required this.name,
    required this.rect,
    required this.imageAsset,
    this.flip = Flip.none,
    this.rect2 = Rect.zero,
    this.flip2 = Flip.none,
    this.clampingRect = Rect.largest,
    this.constraints = const BoxConstraints(minWidth: 0, minHeight: 0),
    this.flipRectWhileResizing = true,
    this.flipChild = true,
    this.clampingEnabled = false,
    this.constraintsEnabled = false,
    this.resizable = true,
    this.movable = true,
    this.hideHandlesWhenNotResizable = true,
  });
}
94
likes
0
points
3.37k
downloads

Publisher

verified publisherhyperdesigned.dev

Weekly Downloads

A Flutter implementation of box_transform package that provides easy 2D box transform operations with advanced resizing of rect in UI.

Repository (GitHub)
View/report issues

Documentation

Documentation

License

unknown (license)

Dependencies

box_transform, flutter, vector_math

More

Packages that depend on flutter_box_transform