fix(ui): restore path placeholders and window shortcut

This commit is contained in:
Levi committed 2026-09-17 21:08:30 +08:00
1 parent c703c52cbc
commit 051d4267a5
8 files changed
+257 -186

No files matched your search

@@ -1,43 +0,0 @@
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,7 +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';
import 'app_form_row.dart';
class AppHttpHeadersController extends ChangeNotifier {
AppHttpHeadersController({Map<String, String>? headers, Iterable<String> defaultNames = const []}) {
@@ -84,132 +84,60 @@ class AppHttpHeadersEditor extends StatelessWidget {
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: [
Widget build(BuildContext context) => AppFormRow(
key: ValueKey('$keyPrefix-editor'),
label: label,
direction: stacked ? Axis.vertical : Axis.horizontal,
child: AnimatedBuilder(
animation: controller,
builder: (context, _) => Column(
children: [
for (var index = 0; index < controller._rows.length; index++) ...[
Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w500),
flex: 382,
child: _HeaderTextField(
key: ValueKey('$keyPrefix-name-$index'),
controller: controller._rows[index].name,
hintText: context.l10n.httpHeaderName,
),
),
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(width: 8),
Expanded(
flex: 618,
child: _HeaderTextField(
key: ValueKey('$keyPrefix-value-$index'),
controller: controller._rows[index].value,
hintText: context.l10n.httpHeaderValue,
),
),
],
),
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(
const SizedBox(width: 4),
_HeaderAction(
key: ValueKey('$keyPrefix-remove-$index'),
icon: Icons.delete_outline,
icon: Icons.remove,
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,
children: [
SizedBox(
width: 104,
child: Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
label,
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w500),
),
),
),
const SizedBox(width: 12),
Expanded(
child: AnimatedBuilder(
animation: controller,
builder: (context, _) => Column(
children: [
for (var index = 0; index < controller._rows.length; index++) ...[
Row(
children: [
Expanded(
flex: 382,
child: _HeaderTextField(
key: ValueKey('$keyPrefix-name-$index'),
controller: controller._rows[index].name,
hintText: context.l10n.httpHeaderName,
),
),
const SizedBox(width: 8),
Expanded(
flex: 618,
child: _HeaderTextField(
key: ValueKey('$keyPrefix-value-$index'),
controller: controller._rows[index].value,
hintText: context.l10n.httpHeaderValue,
),
),
const SizedBox(width: 4),
_HeaderAction(
key: ValueKey('$keyPrefix-remove-$index'),
icon: Icons.remove,
onPressed: controller._rows.length == 1 ? null : () => controller.removeAt(index),
),
const SizedBox(width: 4),
Visibility(
visible: index == controller._rows.length - 1,
maintainAnimation: true,
maintainSize: true,
maintainState: true,
child: _HeaderAction(
key: index == controller._rows.length - 1 ? ValueKey('$keyPrefix-add') : null,
icon: Icons.add,
onPressed: controller.add,
),
),
],
const SizedBox(width: 4),
Visibility(
visible: index == controller._rows.length - 1,
maintainAnimation: true,
maintainSize: true,
maintainState: true,
child: _HeaderAction(
key: index == controller._rows.length - 1 ? ValueKey('$keyPrefix-add') : null,
icon: Icons.add,
onPressed: controller.add,
),
if (index != controller._rows.length - 1) const SizedBox(height: 8),
],
),
],
),
),
),
],
);
}
if (index != controller._rows.length - 1) const SizedBox(height: 8),
],
],
),
),
);
}
class _HttpHeaderRowControllers {
@@ -0,0 +1,84 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../../l10n/l10n.dart';
import '../theme/app_design_tokens.dart';
import '../theme/app_palette.dart';
/// Explains supported date variables and inserts one at the path's selection.
class AppPathPlaceholderButton extends StatelessWidget {
const AppPathPlaceholderButton({super.key, required this.controller});
final TextEditingController controller;
void _showPlaceholders(BuildContext context) {
final selection = controller.selection;
final now = DateTime.now();
final year = now.year.toString();
final month = now.month.toString().padLeft(2, '0');
final day = now.day.toString().padLeft(2, '0');
final l10n = context.l10n;
final placeholders = [
('%year%', l10n.placeholderYear, year),
('%month%', l10n.placeholderMonth, month),
('%day%', l10n.placeholderDay, day),
('%date%', l10n.placeholderDate, '$year-$month-$day'),
];
const PopoverOverlayHandler().show<void>(
context: context,
alignment: Alignment.topLeft,
anchorAlignment: Alignment.bottomLeft,
offset: const Offset(0, AppDesignTokens.space4),
builder: (popoverContext) => Card(
padding: const EdgeInsets.all(AppDesignTokens.space8),
child: SizedBox(
width: (MediaQuery.sizeOf(context).width - 80).clamp(180.0, 320.0),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (variable, description, example) in placeholders)
GhostButton(
key: ValueKey('path-placeholder-$variable'),
onPressed: () {
final text = controller.text;
final valid = selection.isValid && selection.end <= text.length;
final start = valid ? selection.start : text.length;
final end = valid ? selection.end : text.length;
controller.value = TextEditingValue(
text: text.replaceRange(start, end, variable),
selection: TextSelection.collapsed(offset: start + variable.length),
);
closeOverlay(popoverContext);
},
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$variable — $description', style: const TextStyle(fontSize: 12)),
Text(
l10n.example.replaceAll('@value', example),
style: TextStyle(color: AppPalette.of(context).textMuted, fontSize: 12),
),
],
),
),
),
],
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) => Builder(
builder: (anchorContext) =>
GhostButton(onPressed: () => _showPlaceholders(anchorContext), child: Text(context.l10n.insertPlaceholder)),
);
}
@@ -1,5 +1,6 @@
import 'dart:math' as math;
import 'package:flutter/gestures.dart' show Drag;
import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
@@ -99,6 +100,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
late final Animation<double> _reveal;
final _scrollController = ScrollController();
final _horizontalScrollController = ScrollController();
Drag? _horizontalDrag;
final Map<String, double> _labelWidthCache = {};
TextStyle? _labelMeasurementStyle;
TextDirection? _labelMeasurementDirection;
@@ -138,6 +140,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
@override
void dispose() {
_horizontalDrag?.cancel();
_transitionController.removeStatusListener(_onTransitionStatus);
_transitionController.dispose();
_scrollController.dispose();
@@ -290,26 +293,36 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
),
const SizedBox(width: 4),
Expanded(
child: ClipRect(
child: AnimatedBuilder(
animation: _horizontalScrollController,
builder: (context, child) {
final offset = horizontalScrollEnabled && _horizontalScrollController.hasClients
? _horizontalScrollController.offset
: 0.0;
return Transform.translate(offset: Offset(-offset, 0), child: child);
},
child: OverflowBox(
alignment: Alignment.centerLeft,
minWidth: 0,
maxWidth: double.infinity,
child: SizedBox(
width: nameContentWidth,
child: Text(
data.label,
maxLines: 1,
softWrap: false,
style: contentTextStyle.copyWith(color: palette.textPrimary),
child: GestureDetector(
key: ValueKey('${widget.keyPrefix}-name-scroll-${data.key}'),
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: horizontalScrollEnabled ? _startNameDrag : null,
onHorizontalDragUpdate: horizontalScrollEnabled
? (details) => _horizontalDrag?.update(details)
: null,
onHorizontalDragEnd: horizontalScrollEnabled ? (details) => _horizontalDrag?.end(details) : null,
onHorizontalDragCancel: horizontalScrollEnabled ? () => _horizontalDrag?.cancel() : null,
child: ClipRect(
child: AnimatedBuilder(
animation: _horizontalScrollController,
builder: (context, child) {
final offset = horizontalScrollEnabled && _horizontalScrollController.hasClients
? _horizontalScrollController.offset
: 0.0;
return Transform.translate(offset: Offset(-offset, 0), child: child);
},
child: OverflowBox(
alignment: Alignment.centerLeft,
minWidth: 0,
maxWidth: double.infinity,
child: SizedBox(
width: nameContentWidth,
child: Text(
data.label,
maxLines: 1,
softWrap: false,
style: contentTextStyle.copyWith(color: palette.textPrimary),
),
),
),
),
@@ -331,6 +344,12 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
);
}
void _startNameDrag(DragStartDetails details) {
_horizontalDrag?.cancel();
if (!_horizontalScrollController.hasClients) return;
_horizontalDrag = _horizontalScrollController.position.drag(details, () => _horizontalDrag = null);
}
Widget _buildTreeTransition(BuildContext context, VirtualTreeEntry<FileTreeNode<T>> entry, Widget row) {
if (_transitioningNodeKeys.contains(entry.node.data.key)) {
return SizeTransition(