FB-03: grid-snapped HUD widgets with auto-fit, centered text
Replace free-form fractional HUD widget positioning with a 4x8 int grid (col/row/colSpan/rowSpan), reject drag/resize moves that would overlap another visible widget, and re-place a re-enabled metric into the next free grid slot instead of stacking it on an occupier. Drag/resize now track a local pixel offset during the gesture and only snap/commit once at gesture end. HUD widget text is wrapped in FittedBox(scaleDown) so it never overflows and stays centered at any grid size.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
/// UI-04: the in-memory, authoritative HUD layout during an editing session.
|
||||
/// UI-04/FB-03: the in-memory, authoritative HUD layout during an editing session.
|
||||
///
|
||||
/// Seeded from `Config` once, and written back to it only on [persist] -- not on every
|
||||
/// drag/resize frame, which would hammer `SharedPreferences` mid-drag (the ticket's own
|
||||
@@ -26,24 +26,47 @@ class HudLayoutController extends StateNotifier<Map<HudMetric, HudWidgetLayout>>
|
||||
|
||||
final Config? _config;
|
||||
|
||||
void updatePosition(HudMetric metric, double x, double y) {
|
||||
/// "Reject and no-op" is the collision rule for drag/resize -- simplest correct
|
||||
/// behaviour, and it's equivalent to "revert to last valid" here since `state` is
|
||||
/// never mutated until the candidate passes the check. A drag/resize that would
|
||||
/// overlap another visible widget simply has no effect for that gesture; the widget
|
||||
/// stays at its last valid position.
|
||||
void updatePosition(HudMetric metric, int col, int row) {
|
||||
final current = state[metric];
|
||||
if (current == null) return;
|
||||
state = {...state, metric: current.copyWith(x: x, y: y).clamped()};
|
||||
final candidate = current.copyWith(col: col, row: row).clampedToGrid();
|
||||
if (_overlapsAnyOther(metric, candidate)) return;
|
||||
state = {...state, metric: candidate};
|
||||
}
|
||||
|
||||
void updateSize(HudMetric metric, double width, double height) {
|
||||
void updateSize(HudMetric metric, int colSpan, int rowSpan) {
|
||||
final current = state[metric];
|
||||
if (current == null) return;
|
||||
state = {...state, metric: current.copyWith(width: width, height: height).clamped()};
|
||||
final candidate = current.copyWith(colSpan: colSpan, rowSpan: rowSpan).clampedToGrid();
|
||||
if (_overlapsAnyOther(metric, candidate)) return;
|
||||
state = {...state, metric: candidate};
|
||||
}
|
||||
|
||||
bool _overlapsAnyOther(HudMetric metric, HudWidgetLayout candidate) => state.values
|
||||
.where((l) => l.visible && l.metric != metric)
|
||||
.any((other) => hudRectsOverlap(candidate, other));
|
||||
|
||||
/// A metric turned on for the first time (no meaningfully-placed prior layout) gets
|
||||
/// [HudWidgetLayout.defaultFor] rather than whatever stale position it held from
|
||||
/// before it was last turned off -- the ticket's own acceptance criterion. A metric
|
||||
/// that already has a real saved position keeps it.
|
||||
/// before it was last turned off. A metric that already has a real saved position
|
||||
/// keeps it -- unless that slot is now occupied by another visible widget (the
|
||||
/// "goes crazy" bug this ticket exists to fix), in which case it's placed in the
|
||||
/// next free grid cell instead of popping up on top of the occupier.
|
||||
void setVisible(HudMetric metric, bool visible) {
|
||||
final current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
|
||||
var current = state[metric] ?? HudWidgetLayout.defaultFor(metric);
|
||||
if (visible && _overlapsAnyOther(metric, current.copyWith(visible: true))) {
|
||||
current = HudWidgetLayout.nextFreeSlot(
|
||||
metric,
|
||||
occupied: state,
|
||||
colSpan: current.colSpan,
|
||||
rowSpan: current.rowSpan,
|
||||
);
|
||||
}
|
||||
state = {...state, metric: current.copyWith(visible: visible)};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,94 +1,110 @@
|
||||
/// UI-04: one telemetry widget's position, size, and visibility on the customizable
|
||||
/// HUD -- see the ticket's Design section for why these are fractions (0.0-1.0) of the
|
||||
/// available HUD area rather than absolute pixels: a layout saved on one device or
|
||||
/// orientation still makes sense on another.
|
||||
/// FB-03: one telemetry widget's position, size, and visibility on the customizable
|
||||
/// HUD -- an Android-home-screen-style snapped grid (see the ticket's Design section
|
||||
/// for why: free-form fractional positioning had no concept of a sibling widget, so
|
||||
/// toggling widgets on/off could stack one directly on top of another).
|
||||
library;
|
||||
|
||||
import 'hud_metric.dart';
|
||||
|
||||
/// Legibility floor and a sane ceiling -- a widget must never shrink to the point its
|
||||
/// own number is unreadable, or grow to the point it swallows the whole HUD.
|
||||
const double hudMinWidthFraction = 0.20;
|
||||
const double hudMaxWidthFraction = 0.70;
|
||||
const double hudMinHeightFraction = 0.08;
|
||||
const double hudMaxHeightFraction = 0.40;
|
||||
/// Fixed grid spanning the HUD area -- matches the previous 4-column default-row
|
||||
/// assumption and gives ample vertical room for the 8-metric list.
|
||||
const int hudGridColumns = 4;
|
||||
const int hudGridRows = 8;
|
||||
|
||||
/// Legibility floor and a sane ceiling, in grid cells -- a widget must never shrink to
|
||||
/// the point its own number is unreadable, or grow to the point it swallows the whole
|
||||
/// HUD.
|
||||
const int hudMinSpan = 1;
|
||||
const int hudMaxColSpan = 4;
|
||||
const int hudMaxRowSpan = 3;
|
||||
|
||||
/// True if rectangles [a] and [b] (both in grid-cell coordinates) overlap -- edges that
|
||||
/// merely touch do not count as overlapping.
|
||||
bool hudRectsOverlap(HudWidgetLayout a, HudWidgetLayout b) {
|
||||
final aColEnd = a.col + a.colSpan;
|
||||
final aRowEnd = a.row + a.rowSpan;
|
||||
final bColEnd = b.col + b.colSpan;
|
||||
final bRowEnd = b.row + b.rowSpan;
|
||||
return a.col < bColEnd && aColEnd > b.col && a.row < bRowEnd && aRowEnd > b.row;
|
||||
}
|
||||
|
||||
class HudWidgetLayout {
|
||||
const HudWidgetLayout({
|
||||
required this.metric,
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.col,
|
||||
required this.row,
|
||||
required this.colSpan,
|
||||
required this.rowSpan,
|
||||
required this.visible,
|
||||
});
|
||||
|
||||
final HudMetric metric;
|
||||
|
||||
/// Top-left corner, as a fraction of the HUD area's width/height.
|
||||
final double x;
|
||||
final double y;
|
||||
final double width;
|
||||
final double height;
|
||||
/// Top-left cell, in grid coordinates (0-based).
|
||||
final int col;
|
||||
final int row;
|
||||
|
||||
/// How many cells wide/tall this widget spans.
|
||||
final int colSpan;
|
||||
final int rowSpan;
|
||||
final bool visible;
|
||||
|
||||
HudWidgetLayout copyWith({
|
||||
double? x,
|
||||
double? y,
|
||||
double? width,
|
||||
double? height,
|
||||
int? col,
|
||||
int? row,
|
||||
int? colSpan,
|
||||
int? rowSpan,
|
||||
bool? visible,
|
||||
}) => HudWidgetLayout(
|
||||
metric: metric,
|
||||
x: x ?? this.x,
|
||||
y: y ?? this.y,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
col: col ?? this.col,
|
||||
row: row ?? this.row,
|
||||
colSpan: colSpan ?? this.colSpan,
|
||||
rowSpan: rowSpan ?? this.rowSpan,
|
||||
visible: visible ?? this.visible,
|
||||
);
|
||||
|
||||
/// Corrects a drag/resize result that ended outside the allowed area back to the
|
||||
/// nearest valid position/size -- clamped to the [hudMinWidthFraction]/
|
||||
/// [hudMaxWidthFraction] etc. bounds first (size), then positioned so it can never
|
||||
/// sit even partially outside the 0.0-1.0 HUD area (position), in that order: a
|
||||
/// resize that would push a widget off-screen should shrink it back on-screen, not
|
||||
/// silently reposition it out from under the rider's finger.
|
||||
HudWidgetLayout clamped() {
|
||||
final clampedWidth = width.clamp(hudMinWidthFraction, hudMaxWidthFraction);
|
||||
final clampedHeight = height.clamp(hudMinHeightFraction, hudMaxHeightFraction);
|
||||
final clampedX = x.clamp(0.0, 1.0 - clampedWidth);
|
||||
final clampedY = y.clamp(0.0, 1.0 - clampedHeight);
|
||||
/// A *self-contained* bounds check with no awareness of siblings -- corrects a
|
||||
/// drag/resize result that ended outside the grid back to the nearest valid
|
||||
/// position/size, span first (so a resize that would push a widget off-grid shrinks
|
||||
/// it back on-grid rather than silently repositioning it), then position.
|
||||
HudWidgetLayout clampedToGrid() {
|
||||
final clampedColSpan = colSpan.clamp(hudMinSpan, hudMaxColSpan);
|
||||
final clampedRowSpan = rowSpan.clamp(hudMinSpan, hudMaxRowSpan);
|
||||
final clampedCol = col.clamp(0, hudGridColumns - clampedColSpan);
|
||||
final clampedRow = row.clamp(0, hudGridRows - clampedRowSpan);
|
||||
return HudWidgetLayout(
|
||||
metric: metric,
|
||||
x: clampedX,
|
||||
y: clampedY,
|
||||
width: clampedWidth,
|
||||
height: clampedHeight,
|
||||
col: clampedCol,
|
||||
row: clampedRow,
|
||||
colSpan: clampedColSpan,
|
||||
rowSpan: clampedRowSpan,
|
||||
visible: visible,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'x': x,
|
||||
'y': y,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'col': col,
|
||||
'row': row,
|
||||
'colSpan': colSpan,
|
||||
'rowSpan': rowSpan,
|
||||
'visible': visible,
|
||||
};
|
||||
|
||||
/// Falls back to [defaultFor] rather than throwing on a malformed/partial entry --
|
||||
/// an old saved layout from a future app version with fields this version doesn't
|
||||
/// recognise should degrade to a sane default, not crash Settings on launch.
|
||||
/// an old saved layout (pre-grid, with `x`/`y`/`width`/`height` keys) or one with
|
||||
/// fields this version doesn't recognise should degrade to a sane default, not crash
|
||||
/// Settings on launch.
|
||||
static HudWidgetLayout fromJson(HudMetric metric, Map<String, dynamic> json) {
|
||||
try {
|
||||
return HudWidgetLayout(
|
||||
metric: metric,
|
||||
x: (json['x'] as num).toDouble(),
|
||||
y: (json['y'] as num).toDouble(),
|
||||
width: (json['width'] as num).toDouble(),
|
||||
height: (json['height'] as num).toDouble(),
|
||||
col: (json['col'] as num).toInt(),
|
||||
row: (json['row'] as num).toInt(),
|
||||
colSpan: (json['colSpan'] as num).toInt(),
|
||||
rowSpan: (json['rowSpan'] as num).toInt(),
|
||||
visible: json['visible'] as bool,
|
||||
).clamped();
|
||||
).clampedToGrid();
|
||||
} catch (_) {
|
||||
return HudWidgetLayout.defaultFor(metric);
|
||||
}
|
||||
@@ -98,24 +114,54 @@ class HudWidgetLayout {
|
||||
/// before the rider customises anything, and a metric toggled on for the first time
|
||||
/// (with no saved position) lands somewhere sane rather than stacked on another
|
||||
/// widget. Two rows of four, matching the Stitch mockup's row of cards for however
|
||||
/// many metrics fit in the first row, with the rest continuing below it.
|
||||
/// many metrics fit in the first row, with the rest continuing below it. Purely a
|
||||
/// function of the metric's own index -- these 8 fixed slots never overlap each
|
||||
/// other by construction, so no runtime state is needed here.
|
||||
factory HudWidgetLayout.defaultFor(HudMetric metric) {
|
||||
const columns = 4;
|
||||
const cellWidth = 0.22;
|
||||
const cellHeight = 0.12;
|
||||
const gap = 0.02;
|
||||
const rowSpan = 2;
|
||||
final index = HudMetric.values.indexOf(metric);
|
||||
final row = index ~/ columns;
|
||||
final row = (index ~/ columns) * rowSpan;
|
||||
final col = index % columns;
|
||||
return HudWidgetLayout(
|
||||
metric: metric,
|
||||
x: 0.02 + col * (cellWidth + gap),
|
||||
y: 0.06 + row * (cellHeight + gap),
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
col: col,
|
||||
row: row,
|
||||
colSpan: 1,
|
||||
rowSpan: rowSpan,
|
||||
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first
|
||||
// four enum values are ordered to match, so only those start visible.
|
||||
visible: index < columns,
|
||||
);
|
||||
}
|
||||
|
||||
/// Scans row-major from (0,0) for the first [colSpan]x[rowSpan] slot that doesn't
|
||||
/// overlap any `visible` entry in [occupied]. Falls back to (0,0) unconditionally if
|
||||
/// the grid is fully packed -- a stacked default is better than a crash or an
|
||||
/// exception the rider can't do anything about.
|
||||
static HudWidgetLayout nextFreeSlot(
|
||||
HudMetric metric, {
|
||||
required Map<HudMetric, HudWidgetLayout> occupied,
|
||||
int colSpan = 1,
|
||||
int rowSpan = 2,
|
||||
}) {
|
||||
HudWidgetLayout candidate(int col, int row) => HudWidgetLayout(
|
||||
metric: metric,
|
||||
col: col,
|
||||
row: row,
|
||||
colSpan: colSpan,
|
||||
rowSpan: rowSpan,
|
||||
visible: true,
|
||||
);
|
||||
for (var row = 0; row <= hudGridRows - rowSpan; row++) {
|
||||
for (var col = 0; col <= hudGridColumns - colSpan; col++) {
|
||||
final c = candidate(col, row);
|
||||
final overlapsAny = occupied.values
|
||||
.where((l) => l.visible && l.metric != metric)
|
||||
.any((other) => hudRectsOverlap(c, other));
|
||||
if (!overlapsAny) return c;
|
||||
}
|
||||
}
|
||||
return candidate(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -321,27 +321,33 @@ 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, letterSpacing: 1, color: colors.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_value,
|
||||
style: monoDigits.copyWith(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _valueColor(colors),
|
||||
// FB-03: FittedBox owns sizing here -- structurally impossible to overflow, so
|
||||
// maxLines/overflow are dropped from both Texts. fontSize: 10/18 below 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 10/18px 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, letterSpacing: 1, color: colors.onSurfaceVariant),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_value,
|
||||
style: monoDigits.copyWith(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _valueColor(colors),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user