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:
2026-06-10 18:13:04 +02:00
co-authored by Claude Opus 4.8
parent 91b7dbef4d
commit 75cf14f1cb
7 changed files with 477 additions and 83 deletions
+124 -7
View File
@@ -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;