Merge FB-03: grid-snapped HUD widgets with auto-fit text
# Conflicts: # docs/feedback/FB-03-grid-snapped-hud-widgets.md # lib/src/ui/record/record_screen.dart
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
/// UI-04: one telemetry widget on the customizable HUD -- positioned/sized from a
|
||||
/// [HudWidgetLayout]'s fractions against whatever pixel area it's given, draggable and
|
||||
/// resizable only while [editing] is true.
|
||||
/// UI-04/FB-03: one telemetry widget on the customizable HUD -- positioned/sized from a
|
||||
/// [HudWidgetLayout]'s grid cell against whatever pixel area it's given, draggable and
|
||||
/// resizable only while [editing] is true. Drag/resize snap to whole grid cells on
|
||||
/// gesture end (see [_DraggableResizableHudWidgetState]'s local pixel-offset fields);
|
||||
/// during the gesture itself the widget tracks the finger/handle continuously in
|
||||
/// pixels for a smooth feel.
|
||||
///
|
||||
/// Deliberately does not attach any drag gesture at all when [editing] is false --
|
||||
/// "outside edit mode, a normal tap never moves a widget" is guaranteed structurally
|
||||
@@ -36,11 +39,13 @@ class DraggableResizableHudWidget extends StatefulWidget {
|
||||
final bool editing;
|
||||
final Widget child;
|
||||
|
||||
/// Fractional x/y, already relative to [areaSize] -- not yet clamped; the caller
|
||||
/// (`HudLayoutController.updatePosition`) owns clamping so there is exactly one
|
||||
/// place that logic lives.
|
||||
final void Function(double x, double y) onMoved;
|
||||
final void Function(double width, double height) onResized;
|
||||
/// Final snapped grid coordinates -- called exactly once, at gesture end, not on
|
||||
/// every frame of movement (a local, uncommitted pixel offset tracks the live drag;
|
||||
/// see [_DraggableResizableHudWidgetState]). The caller
|
||||
/// (`HudLayoutController.updatePosition`) owns collision/bounds checking so there is
|
||||
/// exactly one place that logic lives.
|
||||
final void Function(int col, int row) onMoved;
|
||||
final void Function(int colSpan, int rowSpan) onResized;
|
||||
|
||||
@override
|
||||
State<DraggableResizableHudWidget> createState() =>
|
||||
@@ -50,13 +55,27 @@ class DraggableResizableHudWidget extends StatefulWidget {
|
||||
class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidget> {
|
||||
bool _grabbed = false;
|
||||
|
||||
/// Pixels, uncommitted, live only during a move gesture -- painted on top of the
|
||||
/// last-committed grid position so the drag tracks the finger smoothly, then reset
|
||||
/// to zero the instant the gesture ends (whether or not the parent accepted the
|
||||
/// resulting snapped position), which is what makes the widget visually "snap back"
|
||||
/// on a rejected move with no separate accept/reject callback needed.
|
||||
Offset _dragOffset = Offset.zero;
|
||||
|
||||
/// Pixels, uncommitted, live only during a resize gesture -- same idea as
|
||||
/// [_dragOffset].
|
||||
Size _resizeDelta = Size.zero;
|
||||
|
||||
double get _cellWidth => widget.areaSize.width / hudGridColumns;
|
||||
double get _cellHeight => widget.areaSize.height / hudGridRows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final layout = widget.layout;
|
||||
final left = layout.x * widget.areaSize.width;
|
||||
final top = layout.y * widget.areaSize.height;
|
||||
final width = layout.width * widget.areaSize.width;
|
||||
final height = layout.height * widget.areaSize.height;
|
||||
final left = layout.col * _cellWidth + _dragOffset.dx;
|
||||
final top = layout.row * _cellHeight + _dragOffset.dy;
|
||||
final width = layout.colSpan * _cellWidth + _resizeDelta.width;
|
||||
final height = layout.rowSpan * _cellHeight + _resizeDelta.height;
|
||||
|
||||
Widget card = AnimatedScale(
|
||||
scale: _grabbed ? 1.05 : 1.0,
|
||||
@@ -71,13 +90,23 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
|
||||
setState(() => _grabbed = true);
|
||||
},
|
||||
onLongPressMoveUpdate: (details) {
|
||||
widget.onMoved(
|
||||
layout.x + details.offsetFromOrigin.dx / widget.areaSize.width,
|
||||
layout.y + details.offsetFromOrigin.dy / widget.areaSize.height,
|
||||
);
|
||||
setState(() => _dragOffset = details.offsetFromOrigin);
|
||||
},
|
||||
onLongPressEnd: (_) => setState(() => _grabbed = false),
|
||||
onLongPressCancel: () => setState(() => _grabbed = false),
|
||||
onLongPressEnd: (_) {
|
||||
final snappedCol =
|
||||
((layout.col * _cellWidth + _dragOffset.dx) / _cellWidth).round();
|
||||
final snappedRow =
|
||||
((layout.row * _cellHeight + _dragOffset.dy) / _cellHeight).round();
|
||||
widget.onMoved(snappedCol, snappedRow);
|
||||
setState(() {
|
||||
_grabbed = false;
|
||||
_dragOffset = Offset.zero;
|
||||
});
|
||||
},
|
||||
onLongPressCancel: () => setState(() {
|
||||
_grabbed = false;
|
||||
_dragOffset = Offset.zero;
|
||||
}),
|
||||
child: card,
|
||||
);
|
||||
} else {
|
||||
@@ -109,10 +138,23 @@ class _DraggableResizableHudWidgetState extends State<DraggableResizableHudWidge
|
||||
bottom: -8,
|
||||
child: GestureDetector(
|
||||
onPanUpdate: (details) {
|
||||
widget.onResized(
|
||||
layout.width + details.delta.dx / widget.areaSize.width,
|
||||
layout.height + details.delta.dy / widget.areaSize.height,
|
||||
);
|
||||
setState(() {
|
||||
_resizeDelta = Size(
|
||||
_resizeDelta.width + details.delta.dx,
|
||||
_resizeDelta.height + details.delta.dy,
|
||||
);
|
||||
});
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
final snappedColSpan =
|
||||
((layout.colSpan * _cellWidth + _resizeDelta.width) / _cellWidth)
|
||||
.round();
|
||||
final snappedRowSpan =
|
||||
((layout.rowSpan * _cellHeight + _resizeDelta.height) /
|
||||
_cellHeight)
|
||||
.round();
|
||||
widget.onResized(snappedColSpan, snappedRowSpan);
|
||||
setState(() => _resizeDelta = Size.zero);
|
||||
},
|
||||
child: Container(
|
||||
key: const Key('hud-resize-handle'),
|
||||
|
||||
@@ -64,8 +64,9 @@ class _HudEditOverlayState extends ConsumerState<HudEditOverlay> {
|
||||
layout: entry.value,
|
||||
areaSize: areaSize,
|
||||
editing: _editing,
|
||||
onMoved: (x, y) => notifier.updatePosition(entry.key, x, y),
|
||||
onResized: (w, h) => notifier.updateSize(entry.key, w, h),
|
||||
onMoved: (col, row) => notifier.updatePosition(entry.key, col, row),
|
||||
onResized: (colSpan, rowSpan) =>
|
||||
notifier.updateSize(entry.key, colSpan, rowSpan),
|
||||
child: widget.metricBuilder(context, entry.key),
|
||||
),
|
||||
if (_editing)
|
||||
|
||||
@@ -328,31 +328,38 @@ class _HudMetricValue extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
metric.label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10 * scale,
|
||||
letterSpacing: 1,
|
||||
color: colors.onSurfaceVariant,
|
||||
// FB-03: FittedBox owns sizing here -- structurally impossible to overflow, so
|
||||
// maxLines/overflow are dropped from both Texts. fontSize: 10/18 below (still
|
||||
// scaled by V3-05/FB-02's mounted-mode [scale]) are now just the "reference" size
|
||||
// FittedBox scales down from at small widget sizes; the ratio between label and
|
||||
// value size is preserved automatically as it scales. Note: BoxFit.scaleDown never
|
||||
// enlarges past that reference size, so a widget resized to the grid's maximum
|
||||
// span still shows the same reference text, just with more empty space around it
|
||||
// -- not larger text filling the space.
|
||||
return FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
metric.label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10 * scale,
|
||||
letterSpacing: 1,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_value,
|
||||
style: monoDigits.copyWith(
|
||||
fontSize: 18 * scale,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _valueColor(colors),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_value,
|
||||
style: monoDigits.copyWith(
|
||||
fontSize: 18 * scale,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _valueColor(colors),
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user