tickets panel: per-type filter chips (T-343)
A row of toggle chips below the filter box — one per pql ticket type, ordered large→small (Initiative, Epic, Story, Task, Bug), each with its TicketTypeColors dot + border. Single-click toggles a type; double-click isolates it (chart-legend solo, fully reversible); disabling the last enabled type snaps all back on so the list is never blank. ANDed with the text filter; all on by default, nothing persisted. One GestureDetector owns both onTap + onDoubleTap so Flutter disambiguates single vs double. Wireframe updated + approved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,19 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
String? _focusedId;
|
||||
final _focusedKey = GlobalKey();
|
||||
final Set<String> _pinned = {'in_progress', 'ready', 'backlog'};
|
||||
|
||||
/// Type-filter chips (T-343), ordered large→small. Each maps 1:1 to a pql
|
||||
/// ticket type; all on by default. An empty set never persists — toggling off
|
||||
/// the last one snaps all back on, so the list is never mysteriously blank.
|
||||
static const _allTypes = {'initiative', 'epic', 'story', 'task', 'bug'};
|
||||
static const _typeOrder = [
|
||||
('initiative', 'Initiative'),
|
||||
('epic', 'Epic'),
|
||||
('story', 'Story'),
|
||||
('task', 'Task'),
|
||||
('bug', 'Bug'),
|
||||
];
|
||||
final Set<String> _enabledTypes = {..._allTypes};
|
||||
StreamSubscription<Message>? _focusSub;
|
||||
StreamSubscription<SchedulerTick>? _schedulerSub;
|
||||
StreamSubscription<Message>? _changedSub;
|
||||
@@ -44,6 +57,31 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Single-click a chip: toggle that type in/out. Removing the last enabled
|
||||
/// type resets all back on (T-343).
|
||||
void _toggleType(String type) {
|
||||
setState(() {
|
||||
if (_enabledTypes.contains(type)) {
|
||||
_enabledTypes.remove(type);
|
||||
if (_enabledTypes.isEmpty) _enabledTypes.addAll(_allTypes);
|
||||
} else {
|
||||
_enabledTypes.add(type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Double-click a chip: isolate (solo) that type — it on, all others off.
|
||||
/// Double-clicking the already-soloed chip restores all-on (chart-legend
|
||||
/// solo pattern, T-343).
|
||||
void _soloType(String type) {
|
||||
setState(() {
|
||||
final soloed = _enabledTypes.length == 1 && _enabledTypes.contains(type);
|
||||
_enabledTypes
|
||||
..clear()
|
||||
..addAll(soloed ? _allTypes : {type});
|
||||
});
|
||||
}
|
||||
|
||||
static String _sectionForStatus(String? status) => status ?? 'backlog';
|
||||
|
||||
void _onFocus(Message msg) {
|
||||
@@ -132,12 +170,14 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
if (_tickets.isEmpty) return const Padding(padding: EdgeInsets.all(12), child: ClideText('No tickets.\nRun `pql ticket new` to create one.', muted: true));
|
||||
|
||||
final lf = _filter.toLowerCase();
|
||||
final hasFilter = lf.isNotEmpty;
|
||||
final filtered = hasFilter
|
||||
? _tickets
|
||||
.where((t) => t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf))
|
||||
.toList()
|
||||
: _tickets;
|
||||
final hasTextFilter = lf.isNotEmpty;
|
||||
// All-on = "no type filter" (so a full set never hides null-type tickets).
|
||||
final allTypesOn = _enabledTypes.length == _allTypes.length;
|
||||
final filtering = hasTextFilter || !allTypesOn;
|
||||
bool textMatch(_TicketEntry t) =>
|
||||
t.id.toLowerCase().contains(lf) || t.title.toLowerCase().contains(lf) || (t.status ?? '').contains(lf) || (t.type ?? '').contains(lf);
|
||||
bool typeMatch(_TicketEntry t) => allTypesOn || _enabledTypes.contains(t.type);
|
||||
final filtered = _tickets.where((t) => (!hasTextFilter || textMatch(t)) && typeMatch(t)).toList();
|
||||
|
||||
const sections = [
|
||||
('in_progress', 'IN PROGRESS'),
|
||||
@@ -172,6 +212,25 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
),
|
||||
],
|
||||
),
|
||||
// Per-type filter chips (T-343): large→small, all on by default.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 2, 8, 6),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final (type, label) in _typeOrder)
|
||||
_TypeChip(
|
||||
label: label,
|
||||
color: typeColors.forType(type),
|
||||
active: _enabledTypes.contains(type),
|
||||
onToggle: () => _toggleType(type),
|
||||
onSolo: () => _soloType(type),
|
||||
tokens: tokens,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
@@ -183,7 +242,7 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
ClideAccordion(
|
||||
label: label,
|
||||
count: items.length,
|
||||
expanded: hasFilter || _isSectionExpanded(status),
|
||||
expanded: filtering || _isSectionExpanded(status),
|
||||
onToggle: () => _toggle(status),
|
||||
children: [
|
||||
for (final t in items)
|
||||
@@ -205,6 +264,64 @@ class _TicketsViewState extends State<TicketsView> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A type-filter chip (T-343): a type-colored dot + label. Active = filled
|
||||
/// tint + colored border; inactive = muted, no fill. Single-click toggles the
|
||||
/// type; double-click isolates it (chart-legend solo). One [GestureDetector]
|
||||
/// owns both so Flutter disambiguates single vs double.
|
||||
class _TypeChip extends StatelessWidget {
|
||||
const _TypeChip({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.active,
|
||||
required this.onToggle,
|
||||
required this.onSolo,
|
||||
required this.tokens,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final bool active;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback onSolo;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dotColor = active ? color : tokens.globalTextMuted;
|
||||
return Semantics(
|
||||
button: true,
|
||||
toggled: active,
|
||||
label: '$label type filter',
|
||||
child: ClideTooltip(
|
||||
message: 'Click to toggle · double-click to isolate',
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onToggle,
|
||||
onDoubleTap: onSolo,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? color.withAlpha(0x22) : null,
|
||||
border: Border.all(color: active ? color : tokens.buttonBorder),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: active ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TicketEntry {
|
||||
const _TicketEntry({required this.id, required this.title, this.type, this.status, this.priority, this.parentId});
|
||||
final String id;
|
||||
|
||||
Reference in New Issue
Block a user