feat(settings): schema-driven settings engine (T-448)
The core of the settings panel (epic T-444). Subsystems describe a category as data — a SettingsCategory of carded SettingsSections of SettingsFields (toggle / select / text / number / opens-external-file), each bound to a SettingsStore key with help text, a default, and reset-to-default. Registration is declarative: a new SettingsCategoryContribution carries the category; the extension manager routes it into a new kernel SettingsRegistry (exposed on KernelServices), which the panel reads via ClideKernel. Adding a category is now pure data + a contribution — no widget code. SettingsCategoryView renders a category into carded sections per ui-design surface.md: panelHeader card fill, dividerColor border, inputs receding to panelBackground; select reuses the anchored-overlay menu, text/number commit on Enter or blur (numeric clamps to bounds). The modal panel now shows the selected/first registered category, falling back to the empty state. Tests: registry (sort / dedup / notify), contribution routing on activation, renderer (render + toggle/select write-through + reset), modal-with-category. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The schema-driven field renderer (T-448, epic T-444): turns a
|
||||
/// [SettingsCategory] into carded sections of field rows. Each field binds to a
|
||||
/// `SettingsStore` key — read with `get` (falling back to the schema default),
|
||||
/// written with `set` on edit. Rebuilds live as the store changes.
|
||||
///
|
||||
/// Per-field scope tags (T-449) and cross-category search (T-450) layer onto
|
||||
/// the row in their own tickets; the trailing slot here is the reset control.
|
||||
/// Carded layout follows ui-design `surface.md` ("sectioned cards").
|
||||
class SettingsCategoryView extends StatelessWidget {
|
||||
const SettingsCategoryView({super.key, required this.category});
|
||||
|
||||
final SettingsCategory category;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final store = ClideKernel.of(context).settings;
|
||||
// Re-read on every settings change so edits (and resets) reflect at once.
|
||||
return ListenableBuilder(
|
||||
listenable: store,
|
||||
builder: (context, _) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final section in category.sections) _SectionCard(section: section, store: store),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One section: a small-caps header above an elevated card of field rows.
|
||||
class _SectionCard extends StatelessWidget {
|
||||
const _SectionCard({required this.section, required this.store});
|
||||
|
||||
final SettingsSection section;
|
||||
final SettingsStore store;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 2, bottom: 6),
|
||||
child: ClideText(
|
||||
section.label.toUpperCase(),
|
||||
fontSize: clideFontCaption,
|
||||
color: tokens.sidebarSectionHeader,
|
||||
fontFamily: clideMonoFamily,
|
||||
),
|
||||
),
|
||||
ClideSurface(
|
||||
// Card surface (surface.md): panelHeader resolves to the `surface`
|
||||
// palette key (the elevated card tone); inputs inside recede to
|
||||
// panelBackground.
|
||||
color: tokens.panelHeader,
|
||||
border: tokens.dividerColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < section.fields.length; i++) ...[
|
||||
if (i > 0) const ClideDivider(),
|
||||
_FieldRow(field: section.fields[i], store: store),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A label/help block + the field's control + a reset affordance.
|
||||
class _FieldRow extends StatelessWidget {
|
||||
const _FieldRow({required this.field, required this.store});
|
||||
|
||||
final SettingsField field;
|
||||
final SettingsStore store;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final raw = store.get<Object>(field.key);
|
||||
final effective = raw ?? field.defaultValue;
|
||||
final canReset = field.defaultValue != null && effective != field.defaultValue;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(field.label, color: tokens.globalForeground),
|
||||
if (field.help != null && field.help!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: ClideText(field.help!, fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_Control(field: field, value: effective, store: store),
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: canReset
|
||||
? _ResetButton(onTap: () => store.set<Object?>(field.key, field.defaultValue))
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches to the control widget for the field's [SettingsFieldKind].
|
||||
class _Control extends StatelessWidget {
|
||||
const _Control({required this.field, required this.value, required this.store});
|
||||
|
||||
final SettingsField field;
|
||||
final Object? value;
|
||||
final SettingsStore store;
|
||||
|
||||
void _set(Object? v) => store.set<Object?>(field.key, v);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (field.kind) {
|
||||
case SettingsFieldKind.toggle:
|
||||
return _ToggleControl(checked: value == true, onChanged: _set);
|
||||
case SettingsFieldKind.select:
|
||||
return _SelectControl(field: field, value: value?.toString(), onPick: _set);
|
||||
case SettingsFieldKind.text:
|
||||
return _EditControl(field: field, value: value?.toString() ?? '', numeric: false, onCommit: _set);
|
||||
case SettingsFieldKind.number:
|
||||
return _EditControl(field: field, value: value?.toString() ?? '', numeric: true, onCommit: _set);
|
||||
case SettingsFieldKind.file:
|
||||
return _FileControl(field: field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkbox toggle (mirrors the theme-picker high-contrast box).
|
||||
class _ToggleControl extends StatelessWidget {
|
||||
const _ToggleControl({required this.checked, required this.onChanged});
|
||||
|
||||
final bool checked;
|
||||
final void Function(bool value) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
checked: checked,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: () => onChanged(!checked),
|
||||
builder: (ctx, hovered, pressed) => Container(
|
||||
width: 15,
|
||||
height: 15,
|
||||
decoration: BoxDecoration(
|
||||
color: checked ? tokens.buttonBackground : (hovered ? tokens.listItemHoverBackground : null),
|
||||
border: Border.all(color: checked ? tokens.buttonBackground : tokens.modalSurfaceBorder),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: checked ? ClideIcon(const CheckIcon(), size: 11, color: tokens.buttonForeground) : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum picker — current value on an anchored popover of options.
|
||||
class _SelectControl extends StatefulWidget {
|
||||
const _SelectControl({required this.field, required this.value, required this.onPick});
|
||||
|
||||
final SettingsField field;
|
||||
final String? value;
|
||||
final void Function(String value) onPick;
|
||||
|
||||
@override
|
||||
State<_SelectControl> createState() => _SelectControlState();
|
||||
}
|
||||
|
||||
class _SelectControlState extends State<_SelectControl> {
|
||||
final ClideOverlayController _overlay = ClideOverlayController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_overlay.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _label {
|
||||
for (final o in widget.field.options) {
|
||||
if (o.value == widget.value) return o.label;
|
||||
}
|
||||
return widget.value ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
align: ClideAnchorAlign.end,
|
||||
overlayBuilder: (ctx, c) => ClideMenu(
|
||||
onClose: c.close,
|
||||
entries: [
|
||||
for (final o in widget.field.options)
|
||||
ClideMenuItem(
|
||||
label: o.label,
|
||||
active: o.value == widget.value,
|
||||
semanticLabel: '${widget.field.label}: ${o.label}',
|
||||
onSelect: () => widget.onPick(o.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
anchor: Semantics(
|
||||
button: true,
|
||||
label: '${widget.field.label}: $_label. Click to change.',
|
||||
excludeSemantics: true,
|
||||
onTap: _overlay.toggle,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onTap: _overlay.toggle,
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: hovered ? tokens.panelActiveBorder : tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(_label, color: tokens.globalForeground),
|
||||
const SizedBox(width: 6),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline text / numeric editor. Commits on Enter and on focus loss; numeric
|
||||
/// fields parse + clamp to the field's bounds, ignoring unparseable input.
|
||||
class _EditControl extends StatefulWidget {
|
||||
const _EditControl({required this.field, required this.value, required this.numeric, required this.onCommit});
|
||||
|
||||
final SettingsField field;
|
||||
final String value;
|
||||
final bool numeric;
|
||||
final void Function(Object value) onCommit;
|
||||
|
||||
@override
|
||||
State<_EditControl> createState() => _EditControlState();
|
||||
}
|
||||
|
||||
class _EditControlState extends State<_EditControl> {
|
||||
late final TextEditingController _controller = TextEditingController(text: widget.value);
|
||||
late final FocusNode _focus = FocusNode(debugLabel: 'settings-${widget.field.key}');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus.addListener(() {
|
||||
if (!_focus.hasFocus) _commit();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_EditControl old) {
|
||||
super.didUpdateWidget(old);
|
||||
// Reflect external changes (reset, scope flip) only when not being edited.
|
||||
if (!_focus.hasFocus && widget.value != _controller.text) {
|
||||
_controller.text = widget.value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _commit() {
|
||||
final text = _controller.text.trim();
|
||||
if (widget.numeric) {
|
||||
final n = num.tryParse(text);
|
||||
if (n == null) {
|
||||
_controller.text = widget.value; // revert unparseable input
|
||||
return;
|
||||
}
|
||||
var clamped = n;
|
||||
final min = widget.field.min;
|
||||
final max = widget.field.max;
|
||||
if (min != null && clamped < min) clamped = min;
|
||||
if (max != null && clamped > max) clamped = max;
|
||||
// Preserve int vs double per the parsed text.
|
||||
final out = clamped == clamped.roundToDouble() && !text.contains('.') ? clamped.toInt() : clamped;
|
||||
_controller.text = '$out';
|
||||
widget.onCommit(out);
|
||||
} else {
|
||||
widget.onCommit(text);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
textField: true,
|
||||
label: widget.field.label,
|
||||
excludeSemantics: true,
|
||||
child: Container(
|
||||
width: widget.numeric ? 88 : 180,
|
||||
height: 26,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: _focus.hasFocus ? tokens.panelActiveBorder : tokens.dividerColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontMono, color: tokens.globalForeground),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
maxLines: 1,
|
||||
onSubmitted: (_) => _commit(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Opens external file" affordance — a button that runs the field's command.
|
||||
class _FileControl extends StatelessWidget {
|
||||
const _FileControl({required this.field});
|
||||
|
||||
final SettingsField field;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final commands = ClideKernel.of(context).commands;
|
||||
return ClideButton(
|
||||
label: field.label,
|
||||
variant: ClideButtonVariant.subtle,
|
||||
onPressed: field.fileCommand == null ? null : () => commands.execute(field.fileCommand!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset-to-default control — a circular-arrow icon shown when the value
|
||||
/// differs from the schema default.
|
||||
class _ResetButton extends StatelessWidget {
|
||||
const _ResetButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Reset to default',
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
cursor: SystemMouseCursors.click,
|
||||
tooltip: 'Reset to default',
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: ClideIcon(
|
||||
PhosphorIcons.byName('arrow-counter-clockwise'),
|
||||
size: 14,
|
||||
color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:clide/builtin/settings_ui/src/settings_category_view.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -7,19 +8,19 @@ import 'package:flutter/widgets.dart';
|
||||
///
|
||||
/// A centered modal over the dimmed app (hosted by [DialogHost] via
|
||||
/// `ctx.dialog.show`), built from the `modalSurface*` tokens (D-7, no
|
||||
/// Material). It frames the two regions the rest of the epic fills in:
|
||||
/// Material). It frames the two regions the epic fills in:
|
||||
///
|
||||
/// - the **category rail** on the left (navigation lands in T-447; the
|
||||
/// list is data-driven from the schemas each subsystem registers), and
|
||||
/// - the **scrolling carded panel** on the right (the schema field
|
||||
/// renderer is T-448).
|
||||
/// - the **category rail** on the left (interactive navigation lands in
|
||||
/// T-447; the list is data-driven from the registered schemas), and
|
||||
/// - the **scrolling carded panel** on the right (the schema field renderer
|
||||
/// is [SettingsCategoryView], T-448).
|
||||
///
|
||||
/// Until any category registers a schema the panel shows its empty state —
|
||||
/// that is the correct runtime state, not a placeholder. Dismiss with the
|
||||
/// close button, Esc, or a barrier tap (the last handled by [DialogHost]).
|
||||
/// Categories come from the kernel [SettingsRegistry]; until one registers a
|
||||
/// schema the panel shows its empty state. Dismiss with the close button, Esc,
|
||||
/// or a barrier tap (the last handled by [DialogHost]).
|
||||
///
|
||||
/// Wireframe: `docs/design/wireframes/settings/settings-screen.png`.
|
||||
class SettingsModal extends StatelessWidget {
|
||||
class SettingsModal extends StatefulWidget {
|
||||
const SettingsModal({super.key, required this.onDismiss});
|
||||
|
||||
/// Closes the modal. Wired to the dialog router's `dismiss` by the
|
||||
@@ -34,17 +35,26 @@ class SettingsModal extends StatelessWidget {
|
||||
static const double _height = 560;
|
||||
static const double _railWidth = 196;
|
||||
|
||||
@override
|
||||
State<SettingsModal> createState() => _SettingsModalState();
|
||||
}
|
||||
|
||||
class _SettingsModalState extends State<SettingsModal> {
|
||||
/// Selected category id; null falls back to the first registered category.
|
||||
/// The rail sets this in T-447.
|
||||
String? _selectedId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i = ClideKernel.of(context).i18n;
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final title = i.string('modal.title', namespace: ns, placeholder: 'Settings');
|
||||
final title = i.string('modal.title', namespace: SettingsModal.ns, placeholder: 'Settings');
|
||||
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
onDismiss();
|
||||
widget.onDismiss();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
@@ -54,23 +64,23 @@ class SettingsModal extends StatelessWidget {
|
||||
label: title,
|
||||
explicitChildNodes: true,
|
||||
child: ClideSurface(
|
||||
width: _width,
|
||||
height: _height,
|
||||
width: SettingsModal._width,
|
||||
height: SettingsModal._height,
|
||||
color: tokens.modalSurfaceBackground,
|
||||
border: tokens.modalSurfaceBorder,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Header(title: title, onClose: onDismiss),
|
||||
_Header(title: title, onClose: widget.onDismiss),
|
||||
const ClideDivider(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(width: _railWidth, child: const _CategoryRail()),
|
||||
const SizedBox(width: SettingsModal._railWidth, child: _CategoryRail()),
|
||||
const ClideDivider(axis: Axis.vertical),
|
||||
const Expanded(child: _SettingsPanel()),
|
||||
Expanded(child: _SettingsPanel(selectedId: _selectedId)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -128,7 +138,10 @@ class _CloseButton extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, pressed) => Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ClideIcon(const CloseIcon(), size: 16, color: tokens.globalForeground),
|
||||
),
|
||||
),
|
||||
@@ -136,8 +149,8 @@ class _CloseButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Left rail. The category list is populated from registered schemas in
|
||||
/// T-447; for now it shows only its section header.
|
||||
/// Left rail. The interactive category list lands in T-447; for now it shows
|
||||
/// only its section header.
|
||||
class _CategoryRail extends StatelessWidget {
|
||||
const _CategoryRail();
|
||||
|
||||
@@ -162,10 +175,36 @@ class _CategoryRail extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Right panel. The schema-driven field renderer fills this in T-448; with
|
||||
/// no category registered yet it shows the empty state.
|
||||
/// Right panel — renders the selected category's schema (or the first
|
||||
/// registered one) via [SettingsCategoryView]; the empty state shows when no
|
||||
/// category is registered. The region recedes to panelBackground so the
|
||||
/// panelHeader cards pop (surface.md).
|
||||
class _SettingsPanel extends StatelessWidget {
|
||||
const _SettingsPanel();
|
||||
const _SettingsPanel({required this.selectedId});
|
||||
|
||||
final String? selectedId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final registry = ClideKernel.of(context).settingsRegistry;
|
||||
return ColoredBox(
|
||||
color: tokens.panelBackground,
|
||||
child: ListenableBuilder(
|
||||
listenable: registry,
|
||||
builder: (context, _) {
|
||||
final categories = registry.categories;
|
||||
if (categories.isEmpty) return const _EmptyState();
|
||||
final selected = (selectedId == null ? null : registry.byId(selectedId!)) ?? categories.first;
|
||||
return SettingsCategoryView(category: selected);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
Reference in New Issue
Block a user