fix(ui): refine narrow layouts and enable tab swipes

This commit is contained in:
Levi committed 2026-09-15 23:01:48 +08:00
1 parent e47a20a1bd
commit c703c52cbc
17 files changed
+624 -182

No files matched your search

@@ -21,6 +21,7 @@ class AppChoiceSegmentedControl<T extends Object> extends StatelessWidget {
required this.onChanged,
this.buttonKeyPrefix = 'choice',
this.alignment,
this.showIcons = true,
});
final T value;
@@ -28,6 +29,7 @@ class AppChoiceSegmentedControl<T extends Object> extends StatelessWidget {
final ValueChanged<T> onChanged;
final String buttonKeyPrefix;
final WrapAlignment? alignment;
final bool showIcons;
@override
Widget build(BuildContext context) {
@@ -44,6 +46,7 @@ class AppChoiceSegmentedControl<T extends Object> extends StatelessWidget {
option: option,
selected: option.value == value,
palette: palette,
showIcon: showIcons,
onPressed: () => onChanged(option.value),
),
],
@@ -58,12 +61,14 @@ class _ChoiceButton<T extends Object> extends StatelessWidget {
required this.selected,
required this.palette,
required this.onPressed,
required this.showIcon,
});
final AppChoiceOption<T> option;
final bool selected;
final AppPalette palette;
final VoidCallback onPressed;
final bool showIcon;
@override
Widget build(BuildContext context) {
@@ -82,8 +87,10 @@ class _ChoiceButton<T extends Object> extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(option.icon, size: 15, color: selected ? palette.textPrimary : palette.textSecondary),
const SizedBox(width: 7),
if (showIcon) ...[
Icon(option.icon, size: 15, color: selected ? palette.textPrimary : palette.textSecondary),
const SizedBox(width: 7),
],
Text(
option.label,
style: TextStyle(
@@ -0,0 +1,49 @@
import 'package:flutter/widgets.dart';
import '../theme/app_design_tokens.dart';
import 'app_form_row.dart';
/// Related fields that share a row on wide forms and get individual labels when stacked.
class AppFormPair extends StatelessWidget {
const AppFormPair({
super.key,
required this.firstLabel,
required this.secondLabel,
required this.first,
required this.second,
this.direction = Axis.horizontal,
this.firstFlex = 1,
this.secondFlex = 1,
});
final String firstLabel;
final String secondLabel;
final Widget first;
final Widget second;
final Axis direction;
final int firstFlex;
final int secondFlex;
@override
Widget build(BuildContext context) {
if (direction == Axis.vertical) {
return Column(
children: [
AppFormRow(label: firstLabel, direction: direction, child: first),
const SizedBox(height: AppDesignTokens.space12),
AppFormRow(label: secondLabel, direction: direction, child: second),
],
);
}
return AppFormRow(
label: firstLabel,
child: Row(
children: [
Expanded(flex: firstFlex, child: first),
const SizedBox(width: AppDesignTokens.space8),
Expanded(flex: secondFlex, child: second),
],
),
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter/widgets.dart';
import '../theme/app_design_tokens.dart';
import '../theme/app_palette.dart';
/// A labelled control whose caller chooses the layout for the available width.
class AppFormRow extends StatelessWidget {
const AppFormRow({super.key, required this.label, required this.child, this.direction = Axis.horizontal});
final String label;
final Widget child;
final Axis direction;
@override
Widget build(BuildContext context) {
final title = Text(
label,
style: TextStyle(color: AppPalette.of(context).textPrimary, fontSize: 13, fontWeight: FontWeight.w500),
);
if (direction == Axis.vertical) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
const SizedBox(height: AppDesignTokens.space8),
SizedBox(width: double.infinity, child: child),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 104,
child: Padding(padding: const EdgeInsets.only(top: 10), child: title),
),
const SizedBox(width: AppDesignTokens.space12),
Expanded(child: child),
],
);
}
}
@@ -0,0 +1,43 @@
import 'package:flutter/widgets.dart';
import '../../l10n/l10n.dart';
import '../theme/app_design_tokens.dart';
import '../theme/app_palette.dart';
/// A single editable name/value pair in a narrow headers form.
class AppHttpHeaderGroup extends StatelessWidget {
const AppHttpHeaderGroup({super.key, required this.name, required this.value, required this.remove});
final Widget name;
final Widget value;
final Widget remove;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return Container(
padding: const EdgeInsets.all(AppDesignTokens.space12),
decoration: BoxDecoration(
border: Border.all(color: palette.border),
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: Text(context.l10n.httpHeaderName)),
remove,
],
),
const SizedBox(height: AppDesignTokens.space4),
name,
const SizedBox(height: AppDesignTokens.space12),
Text(context.l10n.httpHeaderValue),
const SizedBox(height: AppDesignTokens.space8),
value,
],
),
);
}
}
@@ -6,6 +6,7 @@ import '../theme/app_design_tokens.dart';
import '../theme/app_palette.dart';
import '../../l10n/l10n.dart';
import 'app_text_field.dart';
import 'app_http_header_group.dart';
class AppHttpHeadersController extends ChangeNotifier {
AppHttpHeadersController({Map<String, String>? headers, Iterable<String> defaultNames = const []}) {
@@ -69,15 +70,77 @@ class AppHttpHeadersController extends ChangeNotifier {
}
class AppHttpHeadersEditor extends StatelessWidget {
const AppHttpHeadersEditor({super.key, required this.controller, required this.label, required this.keyPrefix});
const AppHttpHeadersEditor({
super.key,
required this.controller,
required this.label,
required this.keyPrefix,
this.stacked = false,
});
final AppHttpHeadersController controller;
final String label;
final String keyPrefix;
final bool stacked;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
if (stacked) {
return AnimatedBuilder(
key: ValueKey('$keyPrefix-editor'),
animation: controller,
builder: (context, _) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w500),
),
),
shad.OutlineButton(
key: ValueKey('$keyPrefix-add'),
onPressed: controller.add,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 18),
const SizedBox(width: AppDesignTokens.space4),
Text(context.l10n.add),
],
),
),
],
),
const SizedBox(height: AppDesignTokens.space8),
for (var index = 0; index < controller._rows.length; index++) ...[
AppHttpHeaderGroup(
key: ObjectKey(controller._rows[index]),
name: _HeaderTextField(
key: ValueKey('$keyPrefix-name-$index'),
controller: controller._rows[index].name,
hintText: context.l10n.httpHeaderName,
),
value: _HeaderTextField(
key: ValueKey('$keyPrefix-value-$index'),
controller: controller._rows[index].value,
hintText: context.l10n.httpHeaderValue,
),
remove: _HeaderAction(
key: ValueKey('$keyPrefix-remove-$index'),
icon: Icons.delete_outline,
onPressed: controller._rows.length == 1 ? null : () => controller.removeAt(index),
),
),
if (index != controller._rows.length - 1) const SizedBox(height: AppDesignTokens.space12),
],
],
),
);
}
return Row(
key: ValueKey('$keyPrefix-editor'),
crossAxisAlignment: CrossAxisAlignment.start,
@@ -0,0 +1,49 @@
import 'package:flutter/widgets.dart';
/// Switches adjacent tabs after a deliberate horizontal swipe over the content.
class AppSwipeTabs<T> extends StatefulWidget {
const AppSwipeTabs({
super.key,
required this.values,
required this.selectedValue,
required this.onSelected,
required this.child,
this.enabled = true,
});
final List<T> values;
final T selectedValue;
final ValueChanged<T> onSelected;
final Widget child;
final bool enabled;
@override
State<AppSwipeTabs<T>> createState() => _AppSwipeTabsState<T>();
}
class _AppSwipeTabsState<T> extends State<AppSwipeTabs<T>> {
double _distance = 0;
void _finish(DragEndDetails details) {
final distance = _distance;
_distance = 0;
final velocity = details.primaryVelocity ?? 0;
if (distance.abs() < 48 && (distance.abs() < 12 || velocity.abs() < 400)) return;
final direction = velocity.abs() >= 400 ? velocity : distance;
final index = widget.values.indexOf(widget.selectedValue);
if (index < 0) return;
final forward = Directionality.of(context) == TextDirection.rtl ? direction > 0 : direction < 0;
final nextIndex = index + (forward ? 1 : -1);
if (nextIndex >= 0 && nextIndex < widget.values.length) widget.onSelected(widget.values[nextIndex]);
}
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: widget.enabled ? (_) => _distance = 0 : null,
onHorizontalDragUpdate: widget.enabled ? (details) => _distance += details.primaryDelta ?? 0 : null,
onHorizontalDragEnd: widget.enabled ? _finish : null,
onHorizontalDragCancel: widget.enabled ? () => _distance = 0 : null,
child: widget.child,
);
}
@@ -69,6 +69,7 @@ class FileTreeView<T> extends StatefulWidget {
this.rowHeight = _treeRowHeight,
this.contentTextStyle,
this.iconSize = 18,
this.initialExpandedDepth,
});
final List<FileTreeItem<T>> items;
@@ -85,6 +86,9 @@ class FileTreeView<T> extends StatefulWidget {
final TextStyle? contentTextStyle;
final double iconSize;
/// Number of directory levels initially expanded; null expands all levels.
final int? initialExpandedDepth;
@override
State<FileTreeView<T>> createState() => _FileTreeViewState<T>();
}
@@ -109,7 +113,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
@override
void initState() {
super.initState();
_nodes = _buildTree(widget.items);
_nodes = _buildTree(widget.items, expandedDepth: widget.initialExpandedDepth);
_allFoldersExpanded = _foldersAreExpanded(_nodes);
_transitionController = AnimationController(vsync: this, duration: _treeTransitionDuration, value: 1)
..addStatusListener(_onTransitionStatus);
@@ -830,8 +834,10 @@ class _BuildNode<T> {
int get size => item?.size ?? children.fold<int>(0, (sum, child) => sum + child.size);
TreeItemNode<FileTreeNode<T>> toTreeItem() {
final childItems = children.map((child) => child.toTreeItem()).toList(growable: false);
TreeItemNode<FileTreeNode<T>> toTreeItem({int? expandedDepth, int depth = 0}) {
final childItems = children
.map((child) => child.toTreeItem(expandedDepth: expandedDepth, depth: depth + 1))
.toList(growable: false);
final leaves = item == null ? [for (final child in childItems) ...child.data.leafItems] : <FileTreeItem<T>>[item!];
return TreeItemNode(
data: FileTreeNode(
@@ -842,13 +848,13 @@ class _BuildNode<T> {
originalIndex: originalIndex,
item: item,
),
expanded: true,
expanded: expandedDepth == null || depth < expandedDepth,
children: childItems,
);
}
}
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items) {
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int? expandedDepth}) {
final root = <_BuildNode<T>>[];
final folders = <String, _BuildNode<T>>{};
var originalIndex = 0;
@@ -885,7 +891,7 @@ List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items) {
}
}
return root.map((node) => node.toTreeItem()).toList(growable: false);
return root.map((node) => node.toTreeItem(expandedDepth: expandedDepth)).toList(growable: false);
}
List<String> _visibleNodeKeysInOrder<T>(List<TreeNode<FileTreeNode<T>>> nodes) {
@@ -89,7 +89,7 @@ class _ResponsiveMenuLayoutState<T> extends State<ResponsiveMenuLayout<T>> {
),
)
.toList(),
selectedValue: widget.selectedValue,
selectedValue: null,
onSelected: widget.onSelected,
footer: widget.sidebarFooter,
mobile: true,
@@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
import '../../core/utils/breakpoints.dart';
import '../theme/app_palette.dart';
import 'secondary_navigation_pane.dart';
import 'app_swipe_tabs.dart';
class ResponsiveNavigationItem<T> {
const ResponsiveNavigationItem({required this.value, required this.label, this.icon, this.count});
@@ -72,7 +73,14 @@ class ResponsiveNavigationLayout<T> extends StatelessWidget {
onSelected: onSelected,
reserveTopSafeArea: mobileHeader == null,
),
Expanded(child: child),
Expanded(
child: AppSwipeTabs<T>(
values: items.map((item) => item.value).toList(growable: false),
selectedValue: selectedValue,
onSelected: onSelected,
child: child,
),
),
],
);
}
@@ -29,7 +29,7 @@ class SecondaryNavigationPane<T> extends StatelessWidget {
final String title;
final List<SecondaryNavigationPaneItem<T>> items;
final T selectedValue;
final T? selectedValue;
final ValueChanged<T> onSelected;
final Widget? footer;
final bool mobile;